diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index eaf22ab3ed1..695975eba2b 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -257,13 +257,43 @@ When your block accepts file uploads, use the basic/advanced mode pattern with ` }, ``` +**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to +a file from a previous block. Gmail attachments are the reference implementation +(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). + +Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a +path). A subblock whose meaning changes based on what the string looks like is impossible to reason +about, forces the params function to sniff the value, and makes the field's type meaningless. Give +each alternative its own subblock outside the pair: + +```typescript +// ✓ Good — the pair is "a file"; other sources are their own fields +{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, +{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, +{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept +{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept + +// ✗ Bad — one field meaning three things, resolved by guessing +{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', + placeholder: 'File reference, media ID, or public URL' }, +``` + +When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce +"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the +other paths ever get a chance to supply the value. + **Critical constraints:** - `canonicalParamId` must NOT match any subblock's `id` in the same block -- Values are stored under subblock `id`, not `canonicalParamId` +- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by + `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations + that each need a file pair need two distinct `canonicalParamId` values. +- All members of a group must share the same `required` status ### Normalizing File Input in tools.config -Use `normalizeFileInput` to handle all input variants: +Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at +serialization, before variable resolution, so a `` file reference is not yet a value +there. ```typescript import { normalizeFileInput } from '@/blocks/utils' @@ -271,34 +301,50 @@ import { normalizeFileInput } from '@/blocks/utils' tools: { access: ['service_upload'], config: { - tool: (params) => { - // Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile + tool: (params) => `service_${params.operation}`, + params: (params) => { + // Read the CANONICAL id, not the subblock ids + const { file: fileParam, ...rest } = params + const file = normalizeFileInput(fileParam, { single: true }) + return { + ...rest, + ...(file ? { file } : {}), } - return `service_${params.operation}` }, }, } ``` -**Why this pattern?** -- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs) -- `canonicalParamId` only controls UI/schema mapping, not runtime values -- `normalizeFileInput` handles JSON strings from advanced mode template resolution +**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, +but it is not what the params function receives. `extractBlockParams` +(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: + +```typescript +const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) +sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted +if (chosen !== undefined) params[group.canonicalId] = chosen +``` + +So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), +`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a +subblock id there yields `undefined` and silently sends no file. + +Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode +and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can +never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution +produces. + +Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so +omitting a key from the returned object does not strip it from what the tool receives. Tools simply +ignore params they do not declare. ### File Input Types in `inputs` -Use `type: 'json'` for file inputs: +Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: ```typescript inputs: { - uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, - fileRef: { type: 'json', description: 'File reference from previous block' }, + file: { type: 'json', description: 'File to upload (UserFile or reference)' }, // Legacy field for backwards compatibility fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, } diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 7014bd93aa6..84689e6be03 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -213,7 +213,7 @@ export const {Service}Block: BlockConfig = { ```typescript // Basic: Visual selector { - id: 'channel', + id: 'channelSelector', type: 'channel-selector', mode: 'basic', canonicalParamId: 'channel', @@ -228,10 +228,19 @@ export const {Service}Block: BlockConfig = { } ``` +Note neither subblock `id` is `channel` — the canonical id is a third name that both members map +onto, and it is the only one that survives serialization. + **Critical Canonical Param Rules:** - `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique per operation/condition context -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter +- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys + groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two + operations that each need their own pair must use two different canonical ids +- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. + A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as + in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate + identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the + mutually exclusive sources `required: false`, and enforce "exactly one" at execution - `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent - Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions - **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) diff --git a/.agents/skills/add-model/SKILL.md b/.agents/skills/add-model/SKILL.md index 34aea3031fb..df17836a790 100644 --- a/.agents/skills/add-model/SKILL.md +++ b/.agents/skills/add-model/SKILL.md @@ -52,6 +52,7 @@ Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, | `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | | `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | | `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | | `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | | `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | | `computerUse` | `anthropic/core.ts` | Dead elsewhere | @@ -146,6 +147,15 @@ If anything matches, run the affected provider tests and update assertions as ne The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). +### Thinking/reasoning models: `streamed` visibility + generated docs + +If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: + +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. +- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. +- Include the `streamed` value (with its source URL) in the verification report when set. + ### Wrong family entirely? - **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. @@ -155,6 +165,7 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b ```bash bun run lint +bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort ``` Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. @@ -201,6 +212,7 @@ Omitting a field is **not the same as verifying it**. Any field you cannot confi - ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) - ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) - ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model - ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x - ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date - ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 15ea661574f..8797df795b3 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -35,10 +35,47 @@ When the user runs `/ship`: 5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. -6. **Run pre-ship checks** from the repo root before staging: - - `bun run lint` to fix formatting issues - - `bun run check:api-validation:strict` to catch boundary contract failures before CI -7. **Stage and commit** the changes with the generated message +6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `Lint and Test` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed. + + **Phase A — regenerate the always-in-repo committed artifacts (parallel), then let step 7 stage whatever changed.** Regenerate only the generators whose inputs live entirely in this repo and that any ordinary code change can drift — `agent-stream-docs:generate` (derives from the provider model registry) and `skills:sync` (derives from `.agents/skills/**`). They write disjoint trees (`apps/docs/…/agent.mdx` vs the `.claude`/`.cursor` command projections), so they parallelize safely, and each is idempotent (a no-op when already in sync): + ```bash + rm -f /tmp/ship-gen-results + for g in agent-stream-docs:generate skills:sync; do + ( bun run "$g" >"/tmp/ship-gen-${g//:/-}.log" 2>&1; echo "$? $g" >>/tmp/ship-gen-results ) & + done + wait + # any non-zero line is a FAILED generator — read /tmp/ship-gen-.log and fix before shipping; + # a silently-failed generate leaves a stale artifact that Phase B / CI then rejects. + # The `exit 1` makes this block itself exit non-zero on failure, so anything gating on the + # command's status (an agent, or a wrapping script) actually stops — do NOT collapse it to + # `grep … && echo ❌ || echo ✅`, which always exits 0 and silently lets ship continue. + if grep -vE '^0 ' /tmp/ship-gen-results; then echo "❌ generator(s) failed — do not ship"; exit 1; fi + echo "✅ artifacts regenerated" + ``` + Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes. + + **Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present. + + **Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree): + ```bash + # autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too — + # a non-zero lint (unfixable errors) must abort before the audits run, not be ignored. + bun run lint || { echo "❌ lint failed — do not ship"; exit 1; } + rm -f /tmp/ship-audit-results + for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \ + check:react-query check:client-boundary check:bare-icons check:icon-paths \ + check:realtime-prune skills:check agent-stream-docs:check; do + ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & + done + wait + # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. + # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is + # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. + if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi + echo "✅ all audits passed" + ``` + If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. +7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 8. **Push to origin** using the current branch name — `--force-with-lease` if step 2's sync check did any history rewrite (a clean rebase or a cherry-pick rebuild) on a branch that had already been pushed once; a plain push would be rejected in exactly the polluted-remote case diff --git a/.agents/skills/validate-model/SKILL.md b/.agents/skills/validate-model/SKILL.md index d40f58d9eee..d7d9cc88c6f 100644 --- a/.agents/skills/validate-model/SKILL.md +++ b/.agents/skills/validate-model/SKILL.md @@ -89,6 +89,7 @@ For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, - [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. - [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs - [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `thinking.streamed` — REQUIRED on Anthropic-family thinking models (`'full'` for generations returning full thinking deltas, `'summary'` for omitted-display generations like Opus 4.7+/Sonnet 5/Fable 5 where Sim requests `display: 'summarized'`); verify against the provider's thinking-display docs. After any change, run `bun run agent-stream-docs:generate` so the Agent block docs table stays in sync (CI diffs it) - [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model - [ ] `toolUsageControl` — provider supports `tool_choice` semantics - [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 882da919fdc..600184d1067 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -256,13 +256,43 @@ When your block accepts file uploads, use the basic/advanced mode pattern with ` }, ``` +**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to +a file from a previous block. Gmail attachments are the reference implementation +(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). + +Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a +path). A subblock whose meaning changes based on what the string looks like is impossible to reason +about, forces the params function to sniff the value, and makes the field's type meaningless. Give +each alternative its own subblock outside the pair: + +```typescript +// ✓ Good — the pair is "a file"; other sources are their own fields +{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, +{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, +{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept +{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept + +// ✗ Bad — one field meaning three things, resolved by guessing +{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', + placeholder: 'File reference, media ID, or public URL' }, +``` + +When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce +"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the +other paths ever get a chance to supply the value. + **Critical constraints:** - `canonicalParamId` must NOT match any subblock's `id` in the same block -- Values are stored under subblock `id`, not `canonicalParamId` +- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by + `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations + that each need a file pair need two distinct `canonicalParamId` values. +- All members of a group must share the same `required` status ### Normalizing File Input in tools.config -Use `normalizeFileInput` to handle all input variants: +Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at +serialization, before variable resolution, so a `` file reference is not yet a value +there. ```typescript import { normalizeFileInput } from '@/blocks/utils' @@ -270,34 +300,50 @@ import { normalizeFileInput } from '@/blocks/utils' tools: { access: ['service_upload'], config: { - tool: (params) => { - // Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile + tool: (params) => `service_${params.operation}`, + params: (params) => { + // Read the CANONICAL id, not the subblock ids + const { file: fileParam, ...rest } = params + const file = normalizeFileInput(fileParam, { single: true }) + return { + ...rest, + ...(file ? { file } : {}), } - return `service_${params.operation}` }, }, } ``` -**Why this pattern?** -- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs) -- `canonicalParamId` only controls UI/schema mapping, not runtime values -- `normalizeFileInput` handles JSON strings from advanced mode template resolution +**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, +but it is not what the params function receives. `extractBlockParams` +(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: + +```typescript +const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) +sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted +if (chosen !== undefined) params[group.canonicalId] = chosen +``` + +So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), +`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a +subblock id there yields `undefined` and silently sends no file. + +Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode +and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can +never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution +produces. + +Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so +omitting a key from the returned object does not strip it from what the tool receives. Tools simply +ignore params they do not declare. ### File Input Types in `inputs` -Use `type: 'json'` for file inputs: +Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: ```typescript inputs: { - uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, - fileRef: { type: 'json', description: 'File reference from previous block' }, + file: { type: 'json', description: 'File to upload (UserFile or reference)' }, // Legacy field for backwards compatibility fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, } diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 8edb6627576..91218c3b139 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -212,7 +212,7 @@ export const {Service}Block: BlockConfig = { ```typescript // Basic: Visual selector { - id: 'channel', + id: 'channelSelector', type: 'channel-selector', mode: 'basic', canonicalParamId: 'channel', @@ -227,10 +227,19 @@ export const {Service}Block: BlockConfig = { } ``` +Note neither subblock `id` is `channel` — the canonical id is a third name that both members map +onto, and it is the only one that survives serialization. + **Critical Canonical Param Rules:** - `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique per operation/condition context -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter +- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys + groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two + operations that each need their own pair must use two different canonical ids +- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. + A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as + in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate + identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the + mutually exclusive sources `required: false`, and enforce "exactly one" at execution - `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent - Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions - **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md index b10b57f6409..9cef8634689 100644 --- a/.claude/commands/add-model.md +++ b/.claude/commands/add-model.md @@ -51,6 +51,7 @@ Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, | `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | | `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | | `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | | `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | | `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | | `computerUse` | `anthropic/core.ts` | Dead elsewhere | @@ -145,6 +146,15 @@ If anything matches, run the affected provider tests and update assertions as ne The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). +### Thinking/reasoning models: `streamed` visibility + generated docs + +If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: + +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. +- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. +- Include the `streamed` value (with its source URL) in the verification report when set. + ### Wrong family entirely? - **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. @@ -154,6 +164,7 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b ```bash bun run lint +bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort ``` Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. @@ -200,6 +211,7 @@ Omitting a field is **not the same as verifying it**. Any field you cannot confi - ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) - ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) - ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model - ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x - ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date - ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index 0a5f01935b5..fdd40c011e6 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -34,10 +34,47 @@ When the user runs `/ship`: 5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. -6. **Run pre-ship checks** from the repo root before staging: - - `bun run lint` to fix formatting issues - - `bun run check:api-validation:strict` to catch boundary contract failures before CI -7. **Stage and commit** the changes with the generated message +6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `Lint and Test` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed. + + **Phase A — regenerate the always-in-repo committed artifacts (parallel), then let step 7 stage whatever changed.** Regenerate only the generators whose inputs live entirely in this repo and that any ordinary code change can drift — `agent-stream-docs:generate` (derives from the provider model registry) and `skills:sync` (derives from `.agents/skills/**`). They write disjoint trees (`apps/docs/…/agent.mdx` vs the `.claude`/`.cursor` command projections), so they parallelize safely, and each is idempotent (a no-op when already in sync): + ```bash + rm -f /tmp/ship-gen-results + for g in agent-stream-docs:generate skills:sync; do + ( bun run "$g" >"/tmp/ship-gen-${g//:/-}.log" 2>&1; echo "$? $g" >>/tmp/ship-gen-results ) & + done + wait + # any non-zero line is a FAILED generator — read /tmp/ship-gen-.log and fix before shipping; + # a silently-failed generate leaves a stale artifact that Phase B / CI then rejects. + # The `exit 1` makes this block itself exit non-zero on failure, so anything gating on the + # command's status (an agent, or a wrapping script) actually stops — do NOT collapse it to + # `grep … && echo ❌ || echo ✅`, which always exits 0 and silently lets ship continue. + if grep -vE '^0 ' /tmp/ship-gen-results; then echo "❌ generator(s) failed — do not ship"; exit 1; fi + echo "✅ artifacts regenerated" + ``` + Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes. + + **Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present. + + **Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree): + ```bash + # autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too — + # a non-zero lint (unfixable errors) must abort before the audits run, not be ignored. + bun run lint || { echo "❌ lint failed — do not ship"; exit 1; } + rm -f /tmp/ship-audit-results + for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \ + check:react-query check:client-boundary check:bare-icons check:icon-paths \ + check:realtime-prune skills:check agent-stream-docs:check; do + ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & + done + wait + # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. + # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is + # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. + if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi + echo "✅ all audits passed" + ``` + If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. +7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 8. **Push to origin** using the current branch name — `--force-with-lease` if step 2's sync check did any history rewrite (a clean rebase or a cherry-pick rebuild) on a branch that had already been pushed once; a plain push would be rejected in exactly the polluted-remote case diff --git a/.claude/commands/validate-model.md b/.claude/commands/validate-model.md index 64e589baff4..c49f2bb3ece 100644 --- a/.claude/commands/validate-model.md +++ b/.claude/commands/validate-model.md @@ -88,6 +88,7 @@ For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, - [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. - [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs - [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `thinking.streamed` — REQUIRED on Anthropic-family thinking models (`'full'` for generations returning full thinking deltas, `'summary'` for omitted-display generations like Opus 4.7+/Sonnet 5/Fable 5 where Sim requests `display: 'summarized'`); verify against the provider's thinking-display docs. After any change, run `bun run agent-stream-docs:generate` so the Agent block docs table stays in sync (CI diffs it) - [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model - [ ] `toolUsageControl` — provider supports `tool_choice` semantics - [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU diff --git a/.claude/rules/landing-seo-geo.md b/.claude/rules/landing-seo-geo.md index b29af45a31e..3f69b3993ef 100644 --- a/.claude/rules/landing-seo-geo.md +++ b/.claude/rules/landing-seo-geo.md @@ -1,6 +1,7 @@ --- paths: - "apps/sim/app/(landing)/**/*.tsx" + - "apps/sim/content/**/*.mdx" --- # Landing Page — SEO / GEO @@ -24,3 +25,22 @@ paths: - **Keyword density**: first 150 visible chars of Hero must name "Sim", "AI workspace", "AI agents". - **sr-only summaries**: Hero and Templates each have a `

` (~50 words) as an atomic product/catalog summary for AI citation. - **Specific numbers**: prefer concrete figures ("1,000+ integrations", "15+ AI providers") over vague claims. + +## Citations and linking (`/library`, `/blog`, `/comparisons`) + +The Princeton GEO study (Aggarwal et al., KDD 2024, [arXiv:2311.09735](https://arxiv.org/abs/2311.09735)) found that adding citations, quotations, and statistics were the three strongest of nine tested tactics, worth 30–40% relative lifts in AI-answer visibility. Sourcing is also what makes a claim checkable by a human reader. + +- **Every third-party factual claim carries an outbound source link.** Pricing, rate limits, feature availability, licensing, compliance certifications — link the primary source (the vendor's own pricing page, docs, changelog, or license file), not a secondary blog. External links get `rel="noopener noreferrer"`. +- **Prefer the primary source over a roundup.** Citing another vendor's comparison post to substantiate a fact about them is second-hand and ages badly. +- **Internal links: 3–5 per library post**, pointing at genuinely related library entries, using real `href`s (Next `` in TSX; a plain markdown link in MDX). A post with zero internal links is a dead end for crawlers and readers alike. +- **Never fabricate a citation.** An unlinked claim is better than a link that does not substantiate it. If a number cannot be sourced, cut the number. + +## Freshness + +Answer engines weight recency to avoid repeating stale facts, and a reader deciding whether to trust a pricing comparison wants to know when it was last checked. The vendor-published "fresh content earns Nx more citations" figures are directional, not measured — the reason to do this is that both signals must agree and both must be real. + +- **Emit `dateModified`** in the page's structured data (JSON-LD or microdata), and emit it exactly once per document. +- **Show the same date to the reader.** `/comparisons/[provider]` renders "Last verified …" from `getLatestVerifiedDate()`; `/library` and `/blog` posts render "Updated …" next to the publish date. A date that exists only in metadata is invisible to a reader deciding whether to trust the page. +- **Only surface a modified date when it differs from the publish date** — an "Updated" label on the publish day is noise. +- **Bump the date only on a substantive edit.** Touching frontmatter without changing the content is date-washing; it degrades the signal for every other page on the domain. +- **Comparison facts are dated at the fact level.** Every `Fact` in `apps/sim/lib/compare/data` carries `sources: [{ url, label, asOf }]`. Re-checking a fact means updating its `asOf`, which flows through `getLatestVerifiedDate()` to the visible date, the JSON-LD, and the sitemap. diff --git a/.claude/rules/sim-integrations.md b/.claude/rules/sim-integrations.md index 0eb3c8f6b4f..0ac54ab9194 100644 --- a/.claude/rules/sim-integrations.md +++ b/.claude/rules/sim-integrations.md @@ -15,5 +15,6 @@ The full authoring instructions — tool/block/icon/trigger scaffolding, SubBloc - Tool IDs are `snake_case` (`service_action`). Register tools in `tools/registry.ts`, blocks in `blocks/registry-maps.ts` (the `BLOCK_REGISTRY` config map + `BLOCK_META_REGISTRY` catalog-meta map, alphabetically — `blocks/registry.ts` holds only the accessor functions), triggers in `triggers/registry.ts`. - Type coercions (`Number()`, etc.) belong in `tools.config.params` (runs at execution, after variable resolution) — never in `tools.config.tool` (runs at serialization; coercing there destroys dynamic `` references). -- `canonicalParamId` must NOT match any subblock's `id`, must be unique per operation/condition context, and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs. +- `canonicalParamId` must NOT match any subblock's `id`, must be unique **block-wide** (groups are keyed by canonical id across every subblock and hold exactly one `basicId`, so two operations that each need a pair need two different canonical ids), and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs — the serializer deletes the subblock IDs and republishes the active member's value under the canonical ID. +- A canonical pair carries ONE concept. For files that is upload (basic) + file reference (advanced), as in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate identifiers (URL, provider asset ID) — give those their own subblocks, mark mutually exclusive sources `required: false`, and enforce "exactly one" at execution. - Blocks must also set the catalog/UI metadata fields `integrationType`, `tags`, `authMode`, `docsLink`, and export a `{Service}BlockMeta` — see the `/add-block` skill's BlockMeta section for details. diff --git a/.cursor/commands/add-block.md b/.cursor/commands/add-block.md index 875da1a0bdf..fbfe5b4c957 100644 --- a/.cursor/commands/add-block.md +++ b/.cursor/commands/add-block.md @@ -251,13 +251,43 @@ When your block accepts file uploads, use the basic/advanced mode pattern with ` }, ``` +**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to +a file from a previous block. Gmail attachments are the reference implementation +(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). + +Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a +path). A subblock whose meaning changes based on what the string looks like is impossible to reason +about, forces the params function to sniff the value, and makes the field's type meaningless. Give +each alternative its own subblock outside the pair: + +```typescript +// ✓ Good — the pair is "a file"; other sources are their own fields +{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, +{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, +{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept +{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept + +// ✗ Bad — one field meaning three things, resolved by guessing +{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', + placeholder: 'File reference, media ID, or public URL' }, +``` + +When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce +"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the +other paths ever get a chance to supply the value. + **Critical constraints:** - `canonicalParamId` must NOT match any subblock's `id` in the same block -- Values are stored under subblock `id`, not `canonicalParamId` +- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by + `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations + that each need a file pair need two distinct `canonicalParamId` values. +- All members of a group must share the same `required` status ### Normalizing File Input in tools.config -Use `normalizeFileInput` to handle all input variants: +Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at +serialization, before variable resolution, so a `` file reference is not yet a value +there. ```typescript import { normalizeFileInput } from '@/blocks/utils' @@ -265,34 +295,50 @@ import { normalizeFileInput } from '@/blocks/utils' tools: { access: ['service_upload'], config: { - tool: (params) => { - // Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile + tool: (params) => `service_${params.operation}`, + params: (params) => { + // Read the CANONICAL id, not the subblock ids + const { file: fileParam, ...rest } = params + const file = normalizeFileInput(fileParam, { single: true }) + return { + ...rest, + ...(file ? { file } : {}), } - return `service_${params.operation}` }, }, } ``` -**Why this pattern?** -- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs) -- `canonicalParamId` only controls UI/schema mapping, not runtime values -- `normalizeFileInput` handles JSON strings from advanced mode template resolution +**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, +but it is not what the params function receives. `extractBlockParams` +(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: + +```typescript +const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) +sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted +if (chosen !== undefined) params[group.canonicalId] = chosen +``` + +So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), +`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a +subblock id there yields `undefined` and silently sends no file. + +Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode +and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can +never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution +produces. + +Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so +omitting a key from the returned object does not strip it from what the tool receives. Tools simply +ignore params they do not declare. ### File Input Types in `inputs` -Use `type: 'json'` for file inputs: +Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: ```typescript inputs: { - uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, - fileRef: { type: 'json', description: 'File reference from previous block' }, + file: { type: 'json', description: 'File to upload (UserFile or reference)' }, // Legacy field for backwards compatibility fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, } diff --git a/.cursor/commands/add-integration.md b/.cursor/commands/add-integration.md index 62eb2de67a8..6267a08b17d 100644 --- a/.cursor/commands/add-integration.md +++ b/.cursor/commands/add-integration.md @@ -207,7 +207,7 @@ export const {Service}Block: BlockConfig = { ```typescript // Basic: Visual selector { - id: 'channel', + id: 'channelSelector', type: 'channel-selector', mode: 'basic', canonicalParamId: 'channel', @@ -222,10 +222,19 @@ export const {Service}Block: BlockConfig = { } ``` +Note neither subblock `id` is `channel` — the canonical id is a third name that both members map +onto, and it is the only one that survives serialization. + **Critical Canonical Param Rules:** - `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique per operation/condition context -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter +- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys + groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two + operations that each need their own pair must use two different canonical ids +- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. + A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as + in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate + identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the + mutually exclusive sources `required: false`, and enforce "exactly one" at execution - `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent - Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions - **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) diff --git a/.cursor/commands/add-model.md b/.cursor/commands/add-model.md index d123d2a2291..61b4a68a9cb 100644 --- a/.cursor/commands/add-model.md +++ b/.cursor/commands/add-model.md @@ -46,6 +46,7 @@ Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, | `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | | `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | | `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | | `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | | `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | | `computerUse` | `anthropic/core.ts` | Dead elsewhere | @@ -140,6 +141,15 @@ If anything matches, run the affected provider tests and update assertions as ne The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). +### Thinking/reasoning models: `streamed` visibility + generated docs + +If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: + +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. +- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. +- Include the `streamed` value (with its source URL) in the verification report when set. + ### Wrong family entirely? - **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. @@ -149,6 +159,7 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b ```bash bun run lint +bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort ``` Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. @@ -195,6 +206,7 @@ Omitting a field is **not the same as verifying it**. Any field you cannot confi - ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) - ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) - ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model - ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x - ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date - ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) diff --git a/.cursor/commands/ship.md b/.cursor/commands/ship.md index 3d63eda0077..3049709300f 100644 --- a/.cursor/commands/ship.md +++ b/.cursor/commands/ship.md @@ -29,10 +29,47 @@ When the user runs `/ship`: 5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. -6. **Run pre-ship checks** from the repo root before staging: - - `bun run lint` to fix formatting issues - - `bun run check:api-validation:strict` to catch boundary contract failures before CI -7. **Stage and commit** the changes with the generated message +6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `Lint and Test` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed. + + **Phase A — regenerate the always-in-repo committed artifacts (parallel), then let step 7 stage whatever changed.** Regenerate only the generators whose inputs live entirely in this repo and that any ordinary code change can drift — `agent-stream-docs:generate` (derives from the provider model registry) and `skills:sync` (derives from `.agents/skills/**`). They write disjoint trees (`apps/docs/…/agent.mdx` vs the `.claude`/`.cursor` command projections), so they parallelize safely, and each is idempotent (a no-op when already in sync): + ```bash + rm -f /tmp/ship-gen-results + for g in agent-stream-docs:generate skills:sync; do + ( bun run "$g" >"/tmp/ship-gen-${g//:/-}.log" 2>&1; echo "$? $g" >>/tmp/ship-gen-results ) & + done + wait + # any non-zero line is a FAILED generator — read /tmp/ship-gen-.log and fix before shipping; + # a silently-failed generate leaves a stale artifact that Phase B / CI then rejects. + # The `exit 1` makes this block itself exit non-zero on failure, so anything gating on the + # command's status (an agent, or a wrapping script) actually stops — do NOT collapse it to + # `grep … && echo ❌ || echo ✅`, which always exits 0 and silently lets ship continue. + if grep -vE '^0 ' /tmp/ship-gen-results; then echo "❌ generator(s) failed — do not ship"; exit 1; fi + echo "✅ artifacts regenerated" + ``` + Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes. + + **Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present. + + **Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree): + ```bash + # autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too — + # a non-zero lint (unfixable errors) must abort before the audits run, not be ignored. + bun run lint || { echo "❌ lint failed — do not ship"; exit 1; } + rm -f /tmp/ship-audit-results + for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \ + check:react-query check:client-boundary check:bare-icons check:icon-paths \ + check:realtime-prune skills:check agent-stream-docs:check; do + ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & + done + wait + # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. + # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is + # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. + if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi + echo "✅ all audits passed" + ``` + If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. +7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 8. **Push to origin** using the current branch name — `--force-with-lease` if step 2's sync check did any history rewrite (a clean rebase or a cherry-pick rebuild) on a branch that had already been pushed once; a plain push would be rejected in exactly the polluted-remote case diff --git a/.cursor/commands/validate-model.md b/.cursor/commands/validate-model.md index 6485b5fd683..e5434725fca 100644 --- a/.cursor/commands/validate-model.md +++ b/.cursor/commands/validate-model.md @@ -83,6 +83,7 @@ For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, - [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. - [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs - [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `thinking.streamed` — REQUIRED on Anthropic-family thinking models (`'full'` for generations returning full thinking deltas, `'summary'` for omitted-display generations like Opus 4.7+/Sonnet 5/Fable 5 where Sim requests `display: 'summarized'`); verify against the provider's thinking-display docs. After any change, run `bun run agent-stream-docs:generate` so the Agent block docs table stays in sync (CI diffs it) - [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model - [ ] `toolUsageControl` — provider supports `tool_choice` semantics - [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a1b7bfedb95..f03ff816348 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -37,4 +37,3 @@ package.json @simstudioai/deps bun.lock @simstudioai/deps **/bun.lock @simstudioai/deps bunfig.toml @simstudioai/deps -.npmrc @simstudioai/deps diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a18541e5751..0d0c66e6b72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: name: Build Dev ECR needs: [detect-version, migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || matrix.gh_runner }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 permissions: contents: read @@ -115,18 +115,25 @@ jobs: include: # Only the app image needs the paid 8-core/32 GB runner: next build # exhausts the free 16 GB one (exit 137). The others build in <5 min. + # bs_runner mirrors that per-image sizing on Blacksmith — a single + # pinned tier put every image on 8 vCPU, where the non-app builds idle + # at 12-15% CPU and under 10% memory. - dockerfile: ./docker/app.Dockerfile ecr_repo_secret: ECR_APP gh_runner: linux-x64-8-core + bs_runner: blacksmith-8vcpu-ubuntu-2404 - dockerfile: ./docker/db.Dockerfile ecr_repo_secret: ECR_MIGRATIONS gh_runner: ubuntu-latest + bs_runner: blacksmith-2vcpu-ubuntu-2404 - dockerfile: ./docker/realtime.Dockerfile ecr_repo_secret: ECR_REALTIME gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 - dockerfile: ./docker/pii.Dockerfile ecr_repo_secret: ECR_PII gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -215,7 +222,7 @@ jobs: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || matrix.gh_runner }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 permissions: contents: read @@ -229,18 +236,22 @@ jobs: ghcr_image: ghcr.io/simstudioai/simstudio ecr_repo_secret: ECR_APP gh_runner: linux-x64-8-core + bs_runner: blacksmith-8vcpu-ubuntu-2404 - dockerfile: ./docker/db.Dockerfile ghcr_image: ghcr.io/simstudioai/migrations ecr_repo_secret: ECR_MIGRATIONS gh_runner: ubuntu-latest + bs_runner: blacksmith-2vcpu-ubuntu-2404 - dockerfile: ./docker/realtime.Dockerfile ghcr_image: ghcr.io/simstudioai/realtime ecr_repo_secret: ECR_REALTIME gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 - dockerfile: ./docker/pii.Dockerfile ghcr_image: ghcr.io/simstudioai/pii ecr_repo_secret: ECR_PII gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -387,7 +398,7 @@ jobs: # never moves a documented tag. build-ghcr-arm64: name: Build ARM64 (GHCR Only) - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404-arm' || matrix.gh_runner }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: @@ -396,19 +407,27 @@ jobs: strategy: fail-fast: false matrix: + # Non-app images sit at 4 vCPU rather than the finer x64 split: the ARM + # sizing data is job-level (8 -> 4 for the whole matrix), not per-image, + # and this job only runs on push to main — an unprovisioned label would + # hang a release in `queued` rather than fail a PR. include: - dockerfile: ./docker/app.Dockerfile image: ghcr.io/simstudioai/simstudio gh_runner: linux-arm64-8-core + bs_runner: blacksmith-8vcpu-ubuntu-2404-arm - dockerfile: ./docker/db.Dockerfile image: ghcr.io/simstudioai/migrations gh_runner: ubuntu-24.04-arm + bs_runner: blacksmith-4vcpu-ubuntu-2404-arm - dockerfile: ./docker/realtime.Dockerfile image: ghcr.io/simstudioai/realtime gh_runner: ubuntu-24.04-arm + bs_runner: blacksmith-4vcpu-ubuntu-2404-arm - dockerfile: ./docker/pii.Dockerfile image: ghcr.io/simstudioai/pii gh_runner: ubuntu-24.04-arm + bs_runner: blacksmith-4vcpu-ubuntu-2404-arm steps: - name: Checkout code diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 0cf31db934f..38457781467 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -40,7 +40,7 @@ permissions: jobs: companion: - runs-on: ubuntu-latest + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 5 steps: - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index b1eb9a28f49..f67ad2ea61c 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -10,7 +10,8 @@ permissions: jobs: process-docs-embeddings: name: Process Documentation Embeddings - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} + # Network-bound on the embeddings API: ~9% CPU and ~3% peak memory on 8 vCPU. + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 00000000000..11944217283 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,157 @@ +name: Helm Chart + +on: + push: + branches: [main, staging, dev] + paths: + - 'helm/sim/**' + - '.github/workflows/helm.yml' + pull_request: + branches: [main, staging, dev] + paths: + - 'helm/sim/**' + - '.github/workflows/helm.yml' + +concurrency: + group: helm-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + chart: + name: Lint, test, and validate chart + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 + with: + version: v3.16.4 + + - name: Helm lint + run: helm lint helm/sim --values helm/sim/ci/default-values.yaml + + - name: Helm unit tests + run: | + # Official helm-unittest image, pinned by immutable digest (tag 3.17.3-0.8.2). + # Run as the runner's UID so the container can write into the bind + # mount (it creates tests/__snapshot__), with a writable HOME for helm. + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/helm/sim:/apps" \ + helmunittest/helm-unittest@sha256:b653db7d5665bc6cec677b15c5eaa1c0377c0de8ac4eb1df58b924478baa21e1 . + + - name: Install kubeconform + run: | + curl -sSL -o /tmp/kubeconform.tar.gz \ + https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz + echo "95f14e87aa28c09d5941f11bd024c1d02fdc0303ccaa23f61cef67bc92619d73 /tmp/kubeconform.tar.gz" | sha256sum -c - + tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform + + - name: Render and validate manifests (default configuration) + run: | + helm template sim helm/sim --namespace sim \ + --values helm/sim/ci/default-values.yaml \ + | /tmp/kubeconform -strict -summary \ + -kubernetes-version 1.29.0 \ + -schema-location default \ + -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' + + - name: Render and validate manifests (all components enabled) + run: | + helm template sim helm/sim --namespace sim \ + --values helm/sim/ci/full-values.yaml \ + | /tmp/kubeconform -strict -summary \ + -kubernetes-version 1.29.0 \ + -schema-location default \ + -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' + + - name: Render every example values file + run: | + set -euo pipefail + for f in helm/sim/examples/values-*.yaml; do + echo "--- $f" + # Examples intentionally omit secrets (their headers document the + # required --set flags), so supply the CI dummies alongside each. + helm template sim helm/sim --namespace sim \ + --values "$f" \ + --values helm/sim/ci/default-values.yaml \ + --set copilot.postgresql.auth.password=ci-dummy-password \ + --set copilot.server.env.AGENT_API_DB_ENCRYPTION_KEY=cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici \ + --set copilot.server.env.INTERNAL_API_SECRET=cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici \ + --set copilot.server.env.LICENSE_KEY=ci-dummy-license \ + --set copilot.server.env.SIM_BASE_URL=https://ci.example.com \ + --set copilot.server.env.SIM_AGENT_API_KEY=ci-dummy-agent-key \ + --set copilot.server.env.REDIS_URL=redis://ci-redis:6379 \ + --set copilot.server.env.OPENAI_API_KEY_1=ci-dummy-openai-key \ + --set externalDatabase.password=ci-dummy-password > /dev/null + done + + version-bump: + name: Chart version bumped + if: github.event_name == 'pull_request' + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Require a Chart.yaml version bump when chart content changes + run: | + set -euo pipefail + base="origin/${{ github.base_ref }}" + git fetch origin "${{ github.base_ref }}" + merge_base=$(git merge-base "$base" HEAD) + changed=$(git diff --name-only "$merge_base" HEAD) + if echo "$changed" | grep -q '^helm/sim/'; then + base_version=$(git show "$merge_base:helm/sim/Chart.yaml" | awk '/^version:/ {print $2}') + head_version=$(awk '/^version:/ {print $2}' helm/sim/Chart.yaml) + echo "base=$base_version head=$head_version" + if [ "$base_version" = "$head_version" ]; then + echo "::error::helm/sim/** changed but Chart.yaml version did not (still $head_version). Bump it per SemVer." + exit 1 + fi + else + echo "No chart changes; skipping." + fi + + install: + name: Install on kind and run helm test + needs: chart + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 25 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 + with: + version: v3.16.4 + + - name: Create kind cluster + uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1 + with: + version: v0.24.0 + + - name: Install chart + run: | + helm install sim helm/sim \ + --namespace sim --create-namespace \ + --values helm/sim/ci/default-values.yaml \ + --values helm/sim/ci/kind-values.yaml \ + --wait --timeout 15m + + - name: Diagnostics on failure + if: failure() + run: | + kubectl -n sim get pods -o wide || true + kubectl -n sim get events --sort-by=.lastTimestamp | tail -40 || true + kubectl -n sim describe pods | tail -100 || true + kubectl -n sim logs deploy/sim-app -c migrations --tail=50 || true + kubectl -n sim logs deploy/sim-app --tail=80 || true + + - name: Run helm test + run: helm test sim --namespace sim --timeout 5m diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 8e7ff3e9eab..1d1fa2d2f62 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -31,6 +31,13 @@ jobs: # namespace on top: untrusted fork runs must never share a cache with # push runs (whose caches feed production image builds) or with trusted # internal-PR runs. + # + # node_modules also keys on the lockfile hash: a sticky disk is a mutable + # volume, and `bun install --frozen-lockfile` adds what the lockfile needs + # without pruning what it dropped, so branches on different lockfiles were + # contaminating each other (a stale @next/swc 16.2.6 outlived the 16.2.11 + # bump). The bun and Turbo caches are content/hash-addressed, so they stay + # shared — that is what keeps a fresh node_modules disk cheap to fill. - name: Mount Bun cache uses: ./.github/actions/cache-mount with: @@ -42,7 +49,7 @@ jobs: uses: ./.github/actions/cache-mount with: provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} + key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ hashFiles('bun.lock') }} path: ./node_modules - name: Mount Turbo cache @@ -140,6 +147,9 @@ jobs: - name: Verify skill projections are in sync run: bun run skills:check + - name: Verify agent stream capability docs are in sync + run: bun run agent-stream-docs:check + - name: Migration safety (zero-downtime) audit run: | if [ "${{ github.event_name }}" = "pull_request" ]; then @@ -190,15 +200,19 @@ jobs: # Next.js production build, in parallel with lint + tests. Sticky disks are # cloned from the last committed snapshot per job and committed last-writer- # wins, so concurrent mounts are safe. The bun/node_modules disks are shared - # with test-build (identical content from the same lockfile — LWW loss is - # harmless), but the Turbo cache gets its own key: with a shared key, only - # the last committer's new entries survive each run, so the test and build - # Turbo entries would evict each other nondeterministically. - # Same next build as the app image, so it needs the same larger runner in - # GitHub mode: it peaks ~21.9 GB and SIGKILLs on the free 16 GB ones. + # with test-build (the lockfile-hashed key means they only ever share when the + # dependency tree really is identical, so LWW loss is harmless), but the Turbo + # cache gets its own key: with a shared key, only the last committer's new + # entries survive each run, so the test and build Turbo entries would evict + # each other nondeterministically. + # + # Runner is sized for the COLD-cache build, which is what OOM-killed the 8vcpu + # tier (23 kills / 1074 runs at 98% of its 30.4 GB): warm peaks ~12 GB, cold + # peaked 51 GB. NODE_OPTIONS' --max-old-space-size caps only Node's JS heap, + # not the native Turbopack workers that dominate, so it cannot prevent this. build: name: Build App - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'linux-x64-8-core' }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }} timeout-minutes: 15 steps: @@ -226,7 +240,7 @@ jobs: uses: ./.github/actions/cache-mount with: provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} + key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ hashFiles('bun.lock') }} path: ./node_modules - name: Mount Turbo cache @@ -248,6 +262,21 @@ jobs: key: ${{ github.repository }}-nextjs-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./apps/sim/.next/cache + # Running out of RAM kills the whole VM and surfaces only as "the runner + # has received a shutdown signal" — no mention of memory, ~12 min in. Warn + # with the real numbers so that failure is a one-line diagnosis instead of + # a mystery. Warn, never fail: a warm build peaks ~12 GB and a partial one + # ~28 GB, so a 32 GB runner still completes plenty of builds, and the + # GitHub fallback is the break-glass path — degrading it to a guaranteed + # failure would be worse than the risk this flags. + - name: Check runner memory headroom + run: | + TOTAL_GB=$(awk '/MemTotal/ {printf "%d", $2/1048576}' /proc/meminfo) + echo "Runner memory: ${TOTAL_GB} GB" + if [ "$TOTAL_GB" -lt 40 ]; then + echo "::warning::Runner has ${TOTAL_GB} GB. A cold-cache build peaks ~51 GB, so this run may be OOM-killed (reported only as 'the runner has received a shutdown signal'). Warm/partial builds should still fit." + fi + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 97b895e2f96..00000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -ignore-scripts=true diff --git a/apps/docs/content/docs/en/agents/skills.mdx b/apps/docs/content/docs/en/agents/skills.mdx index 027ae0def9c..bd608982d3e 100644 --- a/apps/docs/content/docs/en/agents/skills.mdx +++ b/apps/docs/content/docs/en/agents/skills.mdx @@ -18,13 +18,11 @@ Skills use **progressive disclosure** to keep agent context lean: ## Creating Skills -Skills live on the **Integrations** page: click **Integrations** in the workspace sidebar, then switch to the **Skills** tab. It lists every skill in the workspace, searchable by name. +Skills live on the **Integrations** page: click **Integrations** in the workspace sidebar, then switch to the **Skills** tab. It lists every skill in the workspace, searchable by name. Click a skill to open its detail page, where you edit, share, and delete it. ![The Skills tab on the Integrations page](/static/skills/skills-tab.png) -Click **+ Add to Sim** to open the **Add Skill** dialog. The **Create** tab takes three fields: - -![The Add Skill dialog, Create tab](/static/skills/add-skill-create.png) +Click **+ Add to Sim** to open the skill create page, which takes three fields: | Field | Description | |-------|-------------| @@ -38,13 +36,10 @@ Click **+ Add to Sim** to open the **Add Skill** dialog. The **Create** tab take ### Importing skills -The **Import** tab brings in an existing skill in the open [SKILL.md](https://agentskills.io/specification) format, three ways: - -![The Add Skill dialog, Import tab](/static/skills/add-skill-import.png) +Bring in an existing skill in the open [SKILL.md](https://agentskills.io/specification) format two ways: -- **Upload a file** — a `.md` file with YAML frontmatter, or a `.zip` containing a `SKILL.md`. -- **Import from GitHub** — paste a GitHub URL to a `SKILL.md` and click **Fetch**. -- **Paste content** — paste the `SKILL.md` directly. The frontmatter carries the `name` and `description`; the markdown body is the content. +- **Import** — the **Import** action on the create page takes a `.md` file with YAML frontmatter, or a `.zip` containing a `SKILL.md`. +- **Paste content** — paste the `SKILL.md` straight into **Content**. The frontmatter carries the `name` and `description`; the markdown body is the content. Integration pages suggest **curated skills** for their service — open one (HubSpot, for example) and add a suggested skill with one click. @@ -77,6 +72,24 @@ Use when the user asks you to write, optimize, or debug SQL queries. Keep skills focused and under 500 lines. If a skill grows too large, split it into multiple specialized skills. +## Skill Editors + +Everyone in the workspace sees and uses every skill — including members who join later. Nobody needs to be added to a skill to use it. + +Each skill has an explicit **editors** list. Editors can edit the skill, delete it, and manage the editors list. Workspace admins can always do this too — they are editors of every skill automatically and cannot be removed from the list. Whoever creates a skill becomes an editor. + +Open a skill from the Skills tab to manage it. The detail page has the editable fields, a **Share** action for adding editors from your workspace members, and the **Skill Editors** list at the bottom. + + + The editors list controls who can edit a skill — it never affects who can see, use, or run it. A workflow that references a skill always executes it, no matter who runs the workflow. Treat skill content as shared team instructions, not as a secret. + + +## Using Skills in Chat + +Skills work in Chat too. Type `/` in the message box to open the skills menu, then pick a skill — or keep typing to filter by name. The skill appears in your message as a tag, e.g. `/format-markdown`. + +Tagging a skill loads its full instructions into the conversation, so Sim follows them for that request — no waiting for Sim to decide the skill is relevant on its own. + ## Adding Skills to an Agent Open any **Agent** block and find the **Skills** dropdown below the tools section. Select the skills you want the agent to have access to. @@ -156,5 +169,7 @@ import { FAQ } from '@/components/ui/faq' { question: "When should I use skills vs. agent instructions?", answer: "Use skills for knowledge that applies across multiple workflows or changes frequently. Skills are reusable packages that can be attached to any agent. Use agent instructions for task-specific context that is unique to a single agent and workflow. If you find yourself copying the same instructions into multiple agents, that content should be a skill instead." }, { question: "Can permission groups disable skills for certain users?", answer: "Yes. On Enterprise-entitled workspaces, any workspace admin can create a permission group with the disableSkills option enabled. When a user is assigned to such a group in a workspace, the skills dropdown in agent blocks is disabled and they cannot add or use skills in workflows belonging to that workspace." }, { question: "What is the recommended maximum length for skill content?", answer: "Keep skills focused and under 500 lines. If a skill grows too large, split it into multiple specialized skills. Shorter, focused skills are more effective because the agent can load exactly what it needs. A broad skill with too much content can overwhelm the agent and reduce the quality of its responses." }, - { question: "Where do I create and manage skills?", answer: "Click Integrations in the workspace sidebar and switch to the Skills tab. Add Skill creates one from a name (kebab-case, max 64 characters), description (max 1024 characters), and markdown content — or imports an existing SKILL.md from a file, a GitHub URL, or pasted content. Existing skills are edited and deleted from the same tab." }, + { question: "Where do I create and manage skills?", answer: "Click Integrations in the workspace sidebar and switch to the Skills tab. Add to Sim opens a create page taking a name (kebab-case, max 64 characters), description (max 1024 characters), and markdown content — or imports an existing SKILL.md from a file or pasted content. Click any existing skill to open its detail page, where you edit, share, and delete it." }, + { question: "Who can edit a skill, and who can use it?", answer: "Everyone in the workspace — including people who join later — sees and uses every skill without being added to anything. Each skill has an editors list: editors and workspace admins (who are always editors, automatically) can edit, delete, and share the skill, and whoever creates a skill becomes an editor. The editors list never affects who can use or run a skill: a workflow that references a skill always executes it." }, + { question: "Can I use skills in Chat?", answer: "Yes. Type / in the Chat message box to open the skills menu and pick a skill — for example /format-markdown. The tag loads the skill's full instructions into the conversation, so Sim follows them for that request without having to decide on its own to load the skill." }, ]} /> diff --git a/apps/docs/content/docs/en/integrations/whatsapp.mdx b/apps/docs/content/docs/en/integrations/whatsapp.mdx index d6c758ec6ec..32d8ad5c094 100644 --- a/apps/docs/content/docs/en/integrations/whatsapp.mdx +++ b/apps/docs/content/docs/en/integrations/whatsapp.mdx @@ -26,7 +26,7 @@ In Sim, the WhatsApp integration enables your agents to leverage these messaging ## Usage Instructions -Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API. +Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API. Free-form messages only reach a recipient within 24 hours of their last message to you — outside that window WhatsApp requires a pre-approved template. @@ -34,14 +34,14 @@ Integrate WhatsApp into the workflow. Send text, template, media, and interactiv ### `whatsapp_send_message` -Send a text message through the WhatsApp Cloud API. +Send a free-form text message through the WhatsApp Cloud API. Only works inside the 24-hour customer service window — use Send Template to start a conversation. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `message` | string | Yes | Plain text message content to send | +| `message` | string | Yes | Plain text message content to send \(max 4096 characters\) | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | | `previewUrl` | boolean | No | Whether WhatsApp should try to render a link preview for the first URL in the message | @@ -49,19 +49,43 @@ Send a text message through the WhatsApp Cloud API. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | WhatsApp message send success status | -| `messageId` | string | Unique WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the API | -| `messagingProduct` | string | Messaging product returned by the API | -| `inputPhoneNumber` | string | Recipient phone number echoed back by WhatsApp | -| `whatsappUserId` | string | WhatsApp user ID resolved for the recipient | -| `contacts` | array | Recipient contact records returned by WhatsApp | -| ↳ `input` | string | Input phone number sent to the API | -| ↳ `wa_id` | string | WhatsApp user ID associated with the recipient | +| `success` | boolean | Send success status | +| `messageId` | string | WhatsApp message identifier | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | +| `messagingProduct` | string | Messaging product returned by the send API | +| `inputPhoneNumber` | string | Recipient phone number echoed by the send API | +| `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | +| `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | +| `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | +| `from` | string | Sender phone number from the first incoming message | +| `recipientId` | string | Recipient phone number from the first status update in the batch | +| `phoneNumberId` | string | Business phone number ID from the first message or status item in the batch | +| `displayPhoneNumber` | string | Business display phone number from the first message or status item in the batch | +| `text` | string | Text body from the first incoming text message | +| `timestamp` | string | Timestamp from the first message or status item in the batch | +| `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | +| `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | +| `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | +| `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | +| `statuses` | json | All message status objects from the webhook batch, flattened across entries/changes | +| `webhookContacts` | json | All sender contact profiles from the webhook batch | +| `conversation` | json | Conversation metadata from the first status update in the batch \(id, expiration_timestamp, origin.type\) | +| `pricing` | json | Pricing metadata from the first status update in the batch \(billable, pricing_model, category\) | +| `raw` | json | Full structured WhatsApp webhook payload | +| `error` | string | Error information if sending fails | ### `whatsapp_send_template` -Send a pre-approved WhatsApp template message with a language and optional variable components. +Send a pre-approved WhatsApp template message. Required to start a conversation or to message a user outside the 24-hour customer service window. #### Input @@ -79,11 +103,18 @@ Send a pre-approved WhatsApp template message with a language and optional varia | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -92,6 +123,8 @@ Send a pre-approved WhatsApp template message with a language and optional varia | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -104,17 +137,18 @@ Send a pre-approved WhatsApp template message with a language and optional varia ### `whatsapp_send_media` -Send an image, document, video, or audio message via a public link or an uploaded media ID. +Send an image, document, video, audio, or sticker message. Accepts an uploaded file, a media ID, or a public link. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `mediaType` | string | Yes | Type of media to send: image, document, video, or audio | -| `mediaLink` | string | No | Public HTTPS URL of the media \(provide this or mediaId\) | -| `mediaId` | string | No | ID of media previously uploaded to WhatsApp \(provide this or mediaLink\) | -| `caption` | string | No | Optional caption for image, video, or document media | +| `mediaType` | string | Yes | Type of media to send: image, document, video, audio, or sticker | +| `file` | file | No | File to send. Uploaded to WhatsApp first, then sent. Use Upload Media instead when sending the same file repeatedly. | +| `mediaId` | string | No | ID of media previously uploaded to WhatsApp. Alternative to file or mediaLink. | +| `mediaLink` | string | No | Public HTTPS URL of the media. Alternative to file or mediaId. | +| `caption` | string | No | Optional caption for image, video, or document media \(max 1024 characters\). Ignored for audio and sticker. | | `filename` | string | No | Optional file name shown to the recipient for document media | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | @@ -124,11 +158,18 @@ Send an image, document, video, or audio message via a public link or an uploade | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -137,6 +178,8 @@ Send an image, document, video, or audio message via a public link or an uploade | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -156,12 +199,12 @@ Send an interactive WhatsApp message with reply buttons or a selectable list. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `bodyText` | string | Yes | Main body text of the interactive message | -| `headerText` | string | No | Optional plain-text header shown above the body | -| `footerText` | string | No | Optional footer text shown below the body | -| `buttons` | json | No | Reply buttons array \(max 3\), each item: \{ "type": "reply", "reply": \{ "id": "...", "title": "..." \} \}. Provide buttons or sections. | -| `listButtonText` | string | No | Label for the menu button that opens the list \(required when sending a list\) | -| `sections` | json | No | List sections array, each item: \{ "title": "...", "rows": \[\{ "id": "...", "title": "...", "description": "..." \}\] \}. Provide sections or buttons. | +| `bodyText` | string | Yes | Main body text of the interactive message \(max 1024 characters with buttons, 4096 with a list\) | +| `headerText` | string | No | Optional plain-text header shown above the body \(max 60 characters\) | +| `footerText` | string | No | Optional footer text shown below the body \(max 60 characters\) | +| `buttons` | json | No | Reply buttons array \(max 3\), each item: \{ "type": "reply", "reply": \{ "id": "...", "title": "..." \} \}. Button title max 20 characters, id max 256. Provide buttons or sections. | +| `listButtonText` | string | No | Label for the menu button that opens the list, max 20 characters \(required when sending a list\) | +| `sections` | json | No | List sections array \(max 10 sections, 10 rows total\), each item: \{ "title": "...", "rows": \[\{ "id": "...", "title": "...", "description": "..." \}\] \}. Section and row titles max 24 characters, row description max 72. Provide sections or buttons. | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | #### Output @@ -170,11 +213,18 @@ Send an interactive WhatsApp message with reply buttons or a selectable list. | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -183,6 +233,8 @@ Send an interactive WhatsApp message with reply buttons or a selectable list. | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -202,7 +254,7 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `messageId` | string | Yes | ID \(wamid\) of the message to react to | +| `messageId` | string | Yes | ID \(wamid\) of the message to react to. Not delivered if the message is over 30 days old, deleted, not in this chat thread, or itself a reaction. | | `emoji` | string | No | Emoji to react with. Leave empty to remove an existing reaction. | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | @@ -212,11 +264,18 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -225,6 +284,8 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -237,13 +298,14 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis ### `whatsapp_mark_read` -Mark a received WhatsApp message as read so the sender sees blue checkmarks. +Mark a received WhatsApp message as read so the sender sees blue checkmarks, optionally showing a typing indicator. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `messageId` | string | Yes | ID \(wamid\) of the incoming message to mark as read | +| `showTypingIndicator` | boolean | No | Show a typing indicator to the sender while a reply is composed. Dismissed once you respond or after 25 seconds, whichever comes first. | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | #### Output @@ -252,6 +314,47 @@ Mark a received WhatsApp message as read so the sender sees blue checkmarks. | --------- | ---- | ----------- | | `success` | boolean | Whether the message was successfully marked as read | +### `whatsapp_upload_media` + +Upload a file to WhatsApp and get a media ID for sending. Uploaded media is retained for 30 days and can back many sends. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `file` | file | Yes | File to upload. WhatsApp limits: images 5 MB, video and audio 16 MB, documents 100 MB, stickers 500 KB. | +| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `mediaId` | string | WhatsApp media ID. Pass this to Send Media to attach the uploaded file. | +| `fileName` | string | Name of the uploaded file | +| `mimeType` | string | MIME type WhatsApp received the file as | +| `size` | number | Size of the uploaded file in bytes | + +### `whatsapp_get_media` + +Download media a customer sent you. Takes the media ID from an incoming WhatsApp message and stores the file in the workflow. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `mediaId` | string | Yes | Media asset ID from an incoming message, e.g. <whatsapp.messages\[0\].mediaId>. This is not the message ID \(wamid\). | +| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded media stored as a workflow file | +| `mediaId` | string | WhatsApp media ID that was downloaded | +| `mimeType` | string | MIME type reported by WhatsApp | +| `fileSize` | number | Size of the downloaded media in bytes | +| `sha256` | string | SHA-256 hash WhatsApp reported for the media, for integrity checks | + ## Triggers @@ -282,6 +385,9 @@ Trigger workflow from WhatsApp incoming messages and message status webhooks | `text` | string | Text body from the first incoming text message in the batch | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch \(text, image, system, etc.\) | +| `mediaId` | string | Media asset ID from the first incoming media message. Pass to the Download Media operation to fetch the file. Expires after 7 days. | +| `mediaMimeType` | string | MIME type of the first incoming media message | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, read, or failed | | `contact` | json | First sender contact in the batch \(wa_id, profile.name\) | | `webhookContacts` | json | All sender contact profiles from the webhook batch | diff --git a/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx b/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx index ecd4c4321d4..c54118f790d 100644 --- a/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx @@ -1,12 +1,18 @@ --- title: Data Retention -description: Control how long execution logs, deleted resources, and copilot data are kept before permanent deletion +description: Control how long execution logs, deleted resources, and Chat data are kept before permanent deletion — and redact PII from workflow data --- +import { Callout } from 'fumadocs-ui/components/callout' import { FAQ } from '@/components/ui/faq' import { Image } from '@/components/ui/image' -Data Retention lets organization owners and admins on Enterprise plans configure how long three categories of data are kept before they are permanently deleted. The configuration applies to every workspace in the organization. +Data Retention lets organization owners and admins on Enterprise plans control two things: + +1. **Retention periods** — how long execution logs, soft-deleted resources, and Chat data are kept before they are permanently deleted. +2. **PII redaction** — masking personally identifiable information out of your workflow data at up to three points: workflow input, block outputs, and logs. + +Both are configured once at the **organization level** and apply to every workspace, with optional **per-workspace overrides** for workspaces that need different rules. --- @@ -14,21 +20,31 @@ Data Retention lets organization owners and admins on Enterprise plans configure Go to **Settings → Enterprise → Data Retention** in your workspace. -Data Retention settings showing three dropdowns — Log retention, Soft deletion cleanup, and Task cleanup — each set to Forever +Data Retention settings showing the Retention policies list with the Organization default row and its summary of retention periods and PII stages + +The page shows your **retention policies** as a list: -You will see three independent settings, each with the same set of options: **1 day, 3 days, 7 days, 14 days, 30 days, 60 days, 90 days, 180 days, 1 year, 5 years,** or **Forever**. +- The **Organization** row (tagged *Default*) holds the settings that apply to every workspace without its own override. +- Each **workspace override** row below it applies to one or more specific workspaces. -Setting a period to **Forever** means that category of data is never automatically deleted. +Open a row to edit it, or click **Add override** to create a workspace override. Each policy has a **Retention** section, and — when PII redaction is enabled — a **PII redaction** section. --- -## Settings +## Retention periods + +Each policy has three independent retention settings, each with the same set of options: **1 day, 3 days, 7 days, 14 days, 30 days, 60 days, 90 days, 180 days, 1 year, 5 years,** or **Forever**. + +Setting a period to **Forever** means that category of data is never automatically deleted. On a workspace override, each field can also be set to **Inherit from organization** to fall back to the organization default for just that field. ### Log retention -Controls how long **workflow execution logs** are kept. +Controls how long **execution logs** are kept. -When the retention period expires, execution log records are permanently deleted, along with any files associated with those executions stored in cloud storage. +When the retention period expires, log records are permanently deleted, along with any files associated with those executions in cloud storage. This covers: + +- Workflow execution logs +- Background job logs (deployed APIs, schedules, and webhooks) ### Soft deletion cleanup @@ -40,33 +56,83 @@ Resources covered: - Workflows - Workflow folders -- Knowledge bases +- Knowledge bases (and their documents) - Tables - Files - MCP server configurations - Agent memory +- Chat conversations ### Task cleanup -Controls how long **Mothership data** is kept, including: +Controls how long **Chat data** is kept, including: -- Copilot chats and run history +- Chat conversations and run history - Run checkpoints and async tool calls -- Inbox tasks (Sim Mailer) +- Inbox tasks Each setting is independent. You can configure a short log retention period alongside a long soft deletion cleanup period, or any combination that fits your compliance requirements. --- -## Organization-wide configuration +## PII redaction + +When PII redaction is enabled for your organization, each policy gains a **PII redaction** section that masks personally identifiable information — names, emails, phone numbers, credit-card numbers, national IDs, and more — from your workflow data. Sim detects and masks PII with [Microsoft Presidio](https://microsoft.github.io/presidio/); each match is replaced with a placeholder token such as ``. + +Redaction is configured per **stage** — the point in a run where masking is applied. Select a stage, then choose which entity types and custom patterns to redact for it: + +| Stage | What it does | +|---|---| +| **Logs** | Redacts workflow logs when they are persisted. Observability-only — the workflow still runs on the original data. | +| **Workflow input** | Redacts the workflow input **before execution**. The workflow runs on the masked data, which may change its output. | +| **Block outputs** | Masks every block output **before the next block reads it**. Runs in-flight and may change output and execution performance. | + +PII redaction section with the Block outputs stage selected, showing the entity type grid grouped by Common, United States, United Kingdom, and Other regions + + +The **Workflow input** and **Block outputs** stages alter what the workflow computes on, not just what is stored. Redacted data is masked during the run and may affect workflow output. Enable them only where that trade-off is acceptable. + + +### Entity types and language + +For each stage, choose the **entity types** to redact from the searchable grid. They are grouped as: + +- **Common** — person name, email, phone, credit card, IP address, URL, IBAN, crypto wallet, medical license, VIN +- **United States** — SSN, passport, driver's license, bank account, ITIN +- **United Kingdom** — NHS number, National Insurance number +- **Other regions** — Singapore, Australian, and Indian identifiers + +The **Block outputs** stage is restricted to regex- and checksum-based recognizers, so it can run in-flight over large payloads without a performance penalty. Types that need name-model detection — person name, location, date or time — are not offered for that stage. + +Detection is language-aware: pick the **language** whose recognizers should apply. English, Spanish, Italian, Polish, and Finnish are supported, and the grid filters to the identifiers available for the selected language. + + +Some recognizers match loosely and over-redact — US Social Security Number, US bank account number, and Date or time have no checksum and match aggressively. Enable these only where false positives are acceptable. + + +### Custom patterns -Retention is configured at the **organization level**. A single configuration applies to every workspace in the organization — there are no per-workspace overrides. +Beyond the built-in entity types, each stage can redact anything a **regular expression** matches — employee IDs, internal URLs, ticket numbers. Give each pattern a name, a regex, and a replacement token; every match is replaced with the replacement text wrapped in angle brackets (e.g. `EMPLOYEE_ID` → ``). + +--- + +## Per-workspace overrides + +Retention and PII redaction are configured at the **organization level** and apply to every workspace by default. When a workspace needs different rules, add a **workspace override**. + +Add workspace override panel showing the workspace picker, retention fields set to Inherit from organization, and the PII redaction Inherit or Override switch + +- An override targets one or more workspaces (a workspace can belong to only one override). +- Each **retention** field either sets its own period or **inherits** the organization value. +- **PII redaction** on an override is either **Inherit** (use the organization's redaction) or **Override** (workspace-specific). Choosing Override replaces the organization's redaction rules entirely for that workspace — it is not merged stage-by-stage. + +Removing an override returns its workspaces to the organization defaults. --- ## Defaults -By default, all three settings are unconfigured — no data is automatically deleted in any category until you configure it. Setting a period to **Forever** has the same effect as leaving it unconfigured, but makes the intent explicit and allows you to change it later without saving from scratch. +By default, retention settings are unconfigured — no data is automatically deleted in any category, and no PII is redacted, until you configure it. Setting a retention period to **Forever** has the same effect as leaving it unconfigured, but makes the intent explicit and lets you change it later without configuring from scratch. --- @@ -84,13 +150,17 @@ By default, all three settings are unconfigured — no data is automatically del answer: "No. Once the soft deletion cleanup period expires and the cleanup job runs, resources are permanently deleted and cannot be recovered." }, { - question: "Does the retention period apply to all workspaces in my organization?", - answer: "Yes. Retention is configured once per organization and applies to every workspace in the organization." + question: "Does a policy apply to all workspaces?", + answer: "The organization policy applies to every workspace that does not have its own override. Add a per-workspace override to give specific workspaces different retention periods or PII redaction rules." }, { question: "What happens if I shorten the retention period?", answer: "The next cleanup job will delete any data that is older than the new, shorter period — including data that would have been kept under the previous setting. Shortening the period is irreversible for data that falls outside the new window." }, + { + question: "Does PII redaction change how my workflows run?", + answer: "The Logs stage does not — it only masks data as it is persisted, so the workflow runs on the original data. The Workflow input and Block outputs stages do: they mask data in-flight, so the workflow computes on the redacted values, which can change its output." + }, { question: "What is the minimum retention period?", answer: "1 day (24 hours)." @@ -105,10 +175,28 @@ By default, all three settings are unconfigured — no data is automatically del ## Self-hosted setup -### Environment variables +### Retention periods ```bash NEXT_PUBLIC_DATA_RETENTION_ENABLED=true +DATA_RETENTION_ENABLED=true +``` + +Once enabled, retention settings are configurable through **Settings → Enterprise → Data Retention** the same way as Sim Cloud. + +### PII redaction + +PII redaction runs against a standalone [Presidio](https://microsoft.github.io/presidio/) service. Deploy it (see `apps/pii`) and point Sim at it, then enable the redaction surfaces: + +```bash +# The Presidio service exposing /analyze and /anonymize +PII_URL=http://localhost:5001 + +# Expose the log-redaction stage and the Data Retention PII section +PII_REDACTION=true + +# Additionally expose the execution-altering stages (Workflow input, Block outputs) +PII_GRANULAR_REDACTION=true ``` -Once enabled, data retention settings are configurable through **Settings → Enterprise → Data Retention** the same way as Sim Cloud. +`PII_GRANULAR_REDACTION` layers on top of `PII_REDACTION` — with only `PII_REDACTION` enabled, just the **Logs** stage is configurable. diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json index 924a8263768..0b5066c495d 100644 --- a/apps/docs/content/docs/en/platform/enterprise/meta.json +++ b/apps/docs/content/docs/en/platform/enterprise/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "sso", + "verified-domains", "session-policies", "access-control", "custom-blocks", diff --git a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx new file mode 100644 index 00000000000..fe91e17e83e --- /dev/null +++ b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx @@ -0,0 +1,61 @@ +--- +title: Verified Domains +description: Prove ownership of your email domains before configuring single sign-on +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +Verified Domains let organization owners and admins on Enterprise plans prove they control an email domain (like `acme.com`) with a DNS TXT record. Verifying a domain is the security precondition for configuring single sign-on for it. + + + Configuring SSO for a domain requires it to be verified first. Verifying proves your organization controls the domain — without it, anyone could point another company's domain at their own identity provider. Domains you had already configured for SSO are automatically treated as verified. + + +--- + +## Verify a domain + +Go to **Settings → Security → Verified domains** in your organization settings. + +1. Enter the domain, for example `acme.com`, and click **Add domain**. +2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`). +3. Add that TXT record at your DNS provider. +4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**. + + + Some DNS providers — GoDaddy, Namecheap, Hover, and most cPanel panels — append your zone to whatever you type in the host field. If yours does, enter the host with the trailing zone removed, or you will end up with `_sim-challenge.acme.com.acme.com` and verification will never succeed. Managing the `acme.com` zone, `_sim-challenge.acme.com` becomes `_sim-challenge`; verifying the subdomain `eng.acme.com` from that same zone, `_sim-challenge.eng.acme.com` becomes `_sim-challenge.eng`. Cloudflare and Route 53 take the full host as shown. + + +DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. Keep the TXT record published: leaving it in place means the domain stays verifiable if you ever need to verify it again. + +Add each domain you own separately. Subdomains (`eng.acme.com`) are verified independently of the apex. + +--- + +## FAQ + +, rather than the root of your domain — this avoids colliding with your SPF, DMARC, or other root TXT records.', + }, + { + question: 'What happens to domains we already use for SSO?', + answer: + 'They are automatically treated as verified, so existing single sign-on keeps working with no action needed.', + }, + { + question: 'Can two organizations verify the same domain?', + answer: + 'No. A verified domain belongs to exactly one organization. Once verified, another organization cannot claim it.', + }, + { + question: 'What if I remove a verified domain?', + answer: + 'You lose the ownership proof, so you cannot configure SSO for that domain until you re-add and re-verify it. Removing it does not sign anyone out — an already-configured SSO provider keeps working.', + }, + ]} +/> diff --git a/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx b/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx index 33e2f1e7e10..ab15e327d40 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx @@ -9,8 +9,8 @@ import { FAQ } from '@/components/ui/faq' ## Prerequisites -- Kubernetes 1.19+ -- Helm 3.0+ +- Kubernetes 1.25+ +- Helm 3.8+ - PV provisioner support ## Installation @@ -23,47 +23,59 @@ git clone https://github.com/simstudioai/sim.git && cd sim BETTER_AUTH_SECRET=$(openssl rand -hex 32) ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) +CRON_SECRET=$(openssl rand -hex 32) +POSTGRES_PASSWORD=$(openssl rand -hex 24) # Install helm install sim ./helm/sim \ --set app.env.BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ --set app.env.ENCRYPTION_KEY="$ENCRYPTION_KEY" \ --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" \ + --set app.env.CRON_SECRET="$CRON_SECRET" \ + --set postgresql.auth.password="$POSTGRES_PASSWORD" \ --namespace simstudio --create-namespace ``` ## Cloud-Specific Values +These are cloud-tuned **alternatives** to the generic install above — pick one path, don't run both. The commands reuse the `$BETTER_AUTH_SECRET`, `$ENCRYPTION_KEY`, `$INTERNAL_API_SECRET`, `$CRON_SECRET`, and `$POSTGRES_PASSWORD` variables generated in [Installation](#installation) above, so run that block's `openssl` lines first in the same shell. They use `helm upgrade --install`, so they work whether or not a release exists yet. Two caveats when converting an existing generic install rather than starting fresh: (1) **reuse the original secret values** — recover them with `helm get values sim -n simstudio` if your shell no longer has them; supplying a newly generated `ENCRYPTION_KEY` makes every previously encrypted credential (OAuth tokens, provider keys, environment variables) undecryptable. (2) The cloud values rename the bundled PostgreSQL database to `simstudio`, but Postgres only applies that setting on first initialization — add `--set postgresql.auth.database=sim` to keep your existing database. If you'd rather start clean, `helm uninstall sim -n simstudio`, delete its PVCs, and run the cloud command fresh. + ```bash -helm install sim ./helm/sim \ +helm upgrade --install sim ./helm/sim \ --values ./helm/sim/examples/values-aws.yaml \ --set app.env.BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ --set app.env.ENCRYPTION_KEY="$ENCRYPTION_KEY" \ --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" \ + --set app.env.CRON_SECRET="$CRON_SECRET" \ + --set postgresql.auth.password="$POSTGRES_PASSWORD" \ --set app.env.NEXT_PUBLIC_APP_URL="https://sim.yourdomain.com" \ --namespace simstudio --create-namespace ``` ```bash -helm install sim ./helm/sim \ +helm upgrade --install sim ./helm/sim \ --values ./helm/sim/examples/values-azure.yaml \ --set app.env.BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ --set app.env.ENCRYPTION_KEY="$ENCRYPTION_KEY" \ --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" \ + --set app.env.CRON_SECRET="$CRON_SECRET" \ + --set postgresql.auth.password="$POSTGRES_PASSWORD" \ --set app.env.NEXT_PUBLIC_APP_URL="https://sim.yourdomain.com" \ --namespace simstudio --create-namespace ``` ```bash -helm install sim ./helm/sim \ +helm upgrade --install sim ./helm/sim \ --values ./helm/sim/examples/values-gcp.yaml \ --set app.env.BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ --set app.env.ENCRYPTION_KEY="$ENCRYPTION_KEY" \ --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" \ + --set app.env.CRON_SECRET="$CRON_SECRET" \ + --set postgresql.auth.password="$POSTGRES_PASSWORD" \ --set app.env.NEXT_PUBLIC_APP_URL="https://sim.yourdomain.com" \ --namespace simstudio --create-namespace ``` @@ -115,7 +127,7 @@ externalDatabase: ```bash # Port forward for local access -kubectl port-forward deployment/sim-sim-app 3000:3000 -n simstudio +kubectl port-forward deployment/sim-app 3000:3000 -n simstudio # View logs kubectl logs -l app.kubernetes.io/component=app -n simstudio --tail=100 @@ -130,7 +142,7 @@ helm uninstall sim --namespace simstudio

{content}
, })) -import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message' +import { + type ChatMessage, + ClientChatMessage, + escapeHtml, +} from '@/app/(interfaces)/chat/components/message/message' describe('escapeHtml', () => { it('escapes all five HTML-significant characters', () => { @@ -41,3 +58,143 @@ describe('escapeHtml', () => { expect(escapeHtml('')).toBe('') }) }) + +function renderMessage(message: ChatMessage): { container: HTMLDivElement; unmount: () => void } { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + act(() => { + root.render() + }) + return { + container, + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('ClientChatMessage thinking chrome (Step 6)', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('does not show thinking chrome when thinking is absent or empty', () => { + const without = renderMessage({ + id: '1', + type: 'assistant', + content: 'Hello', + timestamp: new Date(), + }) + mounts.push(without.unmount) + expect(without.container.textContent).not.toContain('Thinking') + + const empty = renderMessage({ + id: '2', + type: 'assistant', + content: 'Hello', + thinking: '', + timestamp: new Date(), + }) + mounts.push(empty.unmount) + expect(empty.container.textContent).not.toContain('Thinking') + }) + + it('shows collapsible thinking chrome above the answer after first thinking', () => { + const { container, unmount } = renderMessage({ + id: '3', + type: 'assistant', + content: 'Answer text', + thinking: 'Internal reasoning', + isThinkingStreaming: true, + timestamp: new Date(), + }) + mounts.push(unmount) + + expect(container.textContent).toContain('Thinking…') + expect(container.textContent).toContain('Internal reasoning') + expect(container.textContent).toContain('Answer text') + }) + + it('labels completed thinking as Thought for a moment', () => { + const { container, unmount } = renderMessage({ + id: '4', + type: 'assistant', + content: 'Answer text', + thinking: 'Internal reasoning', + isThinkingStreaming: false, + timestamp: new Date(), + }) + mounts.push(unmount) + + expect(container.textContent).toContain('Thought for a moment') + expect(container.textContent).toContain('Answer text') + }) +}) + +describe('ClientChatMessage tool chrome (Step 8)', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('does not show tool chrome when toolCalls are absent or empty', () => { + const without = renderMessage({ + id: '1', + type: 'assistant', + content: 'Hello', + timestamp: new Date(), + }) + mounts.push(without.unmount) + expect(without.container.textContent).not.toContain('Tools') + expect(without.container.textContent).not.toContain('Using tools') + + const empty = renderMessage({ + id: '2', + type: 'assistant', + content: 'Hello', + toolCalls: [], + timestamp: new Date(), + }) + mounts.push(empty.unmount) + expect(empty.container.textContent).not.toContain('Tools') + }) + + it('shows humanized tool names only (no args) while tools are running', () => { + const { container, unmount } = renderMessage({ + id: '3', + type: 'assistant', + content: 'Answer', + isToolStreaming: true, + toolCalls: [ + { + key: 'agent-1:toolu_1', + blockId: 'agent-1', + id: 'toolu_1', + name: 'http_request', + displayName: 'Http Request', + status: 'running', + }, + ], + timestamp: new Date(), + }) + mounts.push(unmount) + + expect(container.textContent).toContain('Using tools…') + expect(container.textContent).toContain('Http Request') + expect(container.textContent).not.toContain('toolu_1') + expect(container.textContent).not.toContain('args') + expect(container.textContent).toContain('Answer') + }) +}) diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.tsx index f5ec5b623c8..fe7e940bee6 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.tsx @@ -3,6 +3,14 @@ import { memo, useState } from 'react' import { Button, cn, Duplicate, Tooltip } from '@sim/emcn' import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react' +import { + AgentStreamThinkingChrome, + AgentStreamToolCallsChrome, +} from '@/components/agent-stream/agent-stream-chrome' +import type { + AgentStreamToolCall, + AgentStreamToolStatus, +} from '@/components/agent-stream/tool-call-lifecycle' import { ChatFileDownload, ChatFileDownloadAll, @@ -27,6 +35,15 @@ export interface ChatFile { context?: string } +/** Lifecycle status for a tool chip (agent-events-v1). No args/results. */ +export type ChatToolCallStatus = AgentStreamToolStatus + +/** Chat surface tool chip — the shared lifecycle chip plus its block id. */ +export interface ChatToolCall extends AgentStreamToolCall { + blockId: string + displayName: string +} + export interface ChatMessage { id: string content: string | Record @@ -34,6 +51,14 @@ export interface ChatMessage { timestamp: Date isInitialMessage?: boolean isStreaming?: boolean + /** Model thinking text (agent-events-v1). Chrome only when non-empty. */ + thinking?: string + /** True while thinking deltas are still arriving (before first answer chunk / final). */ + isThinkingStreaming?: boolean + /** Tool lifecycle chips (name + status only). Chrome only when non-empty. */ + toolCalls?: ChatToolCall[] + /** True while any tool chip is still `running`. */ + isToolStreaming?: boolean attachments?: ChatAttachment[] files?: ChatFile[] } @@ -81,15 +106,21 @@ function openAttachmentPreview(name: string, dataUrl: string): void { setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000) } +function toolCallsFingerprint(toolCalls: ChatToolCall[] | undefined): string { + if (!toolCalls?.length) return '' + return toolCalls.map((t) => `${t.key}:${t.status}`).join('|') +} + export const ClientChatMessage = memo( function ClientChatMessage({ message }: { message: ChatMessage }) { const [isCopied, setIsCopied] = useState(false) const isJsonObject = typeof message.content === 'object' && message.content !== null - // Since tool calls are now handled via SSE events and stored in message.toolCalls, - // we can use the content directly without parsing + // Answer text is streamed separately from thinking / tool lifecycle events. const cleanTextContent = message.content + const hasThinking = typeof message.thinking === 'string' && message.thinking.length > 0 + const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0 const content = message.type === 'user' ? ( @@ -207,8 +238,19 @@ export const ClientChatMessage = memo(
- {/* Direct content rendering - tool calls are now handled via SSE events */}
+ {hasThinking && ( + + )} + {hasToolCalls && ( + + )}
{isJsonObject ? (
@@ -274,7 +316,12 @@ export const ClientChatMessage = memo(
     return (
       prevProps.message.id === nextProps.message.id &&
       prevProps.message.content === nextProps.message.content &&
+      prevProps.message.thinking === nextProps.message.thinking &&
       prevProps.message.isStreaming === nextProps.message.isStreaming &&
+      prevProps.message.isThinkingStreaming === nextProps.message.isThinkingStreaming &&
+      prevProps.message.isToolStreaming === nextProps.message.isToolStreaming &&
+      toolCallsFingerprint(prevProps.message.toolCalls) ===
+        toolCallsFingerprint(nextProps.message.toolCalls) &&
       prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
       prevProps.message.files?.length === nextProps.message.files?.length
     )
diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx
new file mode 100644
index 00000000000..34c01db49e5
--- /dev/null
+++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx
@@ -0,0 +1,691 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockReadSSEEvents } = vi.hoisted(() => ({
+  mockReadSSEEvents: vi.fn(),
+}))
+
+vi.mock('@/lib/core/utils/sse', () => ({
+  readSSEEvents: mockReadSSEEvents,
+}))
+
+vi.mock('@sim/utils/id', () => ({
+  generateId: () => 'msg-assistant-1',
+}))
+
+import { isChatChunkFrame } from '@/lib/workflows/streaming/agent-stream-protocol'
+import type { ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import { useChatStreaming } from '@/app/(interfaces)/chat/hooks/use-chat-streaming'
+
+describe('isChatChunkFrame', () => {
+  it('accepts plain answer chunks without an event type', () => {
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'hello' })).toBe(true)
+  })
+
+  it('rejects thinking / stream_error / tool frames even if chunk is present', () => {
+    expect(
+      isChatChunkFrame({ blockId: 'a1', chunk: 'leak', event: 'thinking', data: 'thought' })
+    ).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'x', event: 'stream_error' })).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'x', event: 'tool' })).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'x', event: 'final' })).toBe(false)
+  })
+
+  it('rejects frames missing blockId or empty chunk', () => {
+    expect(isChatChunkFrame({ chunk: 'hello' })).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: '' })).toBe(false)
+  })
+})
+
+interface HookHandle {
+  latest: () => ReturnType
+  unmount: () => void
+}
+
+function renderStreamingHook(): HookHandle {
+  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  const container = document.createElement('div')
+  const root: Root = createRoot(container)
+  let latest!: ReturnType
+
+  function Probe() {
+    latest = useChatStreaming()
+    return null
+  }
+
+  act(() => {
+    root.render()
+  })
+
+  return {
+    latest: () => latest,
+    unmount: () => {
+      act(() => {
+        root.unmount()
+      })
+    },
+  }
+}
+
+function makeSseResponse(): Response {
+  return {
+    body: new ReadableStream(),
+  } as Response
+}
+
+async function flushUiBatch() {
+  await act(async () => {
+    await new Promise((resolve) => {
+      requestAnimationFrame(() => resolve())
+    })
+    await new Promise((resolve) => {
+      setTimeout(resolve, 60)
+    })
+  })
+}
+
+describe('useChatStreaming thinking + abort', () => {
+  let handle: HookHandle
+  let messages: ChatMessage[]
+  let setMessages: React.Dispatch>
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    messages = []
+    setMessages = ((updater: React.SetStateAction) => {
+      messages = typeof updater === 'function' ? updater(messages) : updater
+    }) as React.Dispatch>
+    handle = renderStreamingHook()
+
+    // Run rAF immediately so UI batching is deterministic in tests.
+    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+      cb(performance.now())
+      return 1
+    })
+  })
+
+  afterEach(() => {
+    handle.unmount()
+    vi.restoreAllMocks()
+  })
+
+  it('routes thinking to message.thinking and answer chunks to content only', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Let me reason. ',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'More thought.',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Final answer.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBe('Let me reason. More thought.')
+    expect(assistant?.content).toBe('Final answer.')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+  })
+
+  it('clears a block’s live text on chunk_reset and keeps the re-streamed final turn', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      // Turn 1: live preamble, then tools follow → reset.
+      await options.onEvent({ blockId: 'agent-1', chunk: 'Let me check the weather…' })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 't1',
+        name: 'get_weather',
+      })
+      await options.onEvent({ blockId: 'agent-1', event: 'chunk_reset' })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 't1',
+        name: 'get_weather',
+        status: 'success',
+      })
+      // Turn 2: final answer streams live.
+      await options.onEvent({ blockId: 'agent-1', chunk: 'It is ' })
+      await options.onEvent({ blockId: 'agent-1', chunk: '68°F.' })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('It is 68°F.')
+    expect(assistant?.content).not.toContain('Let me check')
+  })
+
+  it('re-registers a reset block at the end so multi-block order matches arrival', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      // Agent A streams provisional text, then resets (tools follow).
+      await options.onEvent({ blockId: 'agent-a', chunk: 'Checking the weather…' })
+      await options.onEvent({ blockId: 'agent-a', event: 'chunk_reset' })
+      // Another block streams while A's tools run.
+      await options.onEvent({ blockId: 'block-b', chunk: 'B output' })
+      // A's final turn re-streams; the server bakes in the cross-block separator.
+      await options.onEvent({ blockId: 'agent-a', chunk: '\n\nIt is 68°F.' })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('B output\n\nIt is 68°F.')
+  })
+
+  it('settles thinking chrome when a tool starts', async () => {
+    let midStreamThinking: boolean | undefined
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({ blockId: 'agent-1', event: 'thinking', data: 'planning…' })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 't1',
+        name: 'get_weather',
+      })
+      // UI flush is synchronous in tests (rAF mocked) — capture mid-stream state.
+      midStreamThinking = messages.find((m) => m.type === 'assistant')?.isThinkingStreaming
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 't1',
+        name: 'get_weather',
+        status: 'success',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    expect(midStreamThinking).toBe(false)
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBe('planning…')
+  })
+
+  it('ignores non-terminal stream_error frames and keeps streaming', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Working…',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'stream_error',
+        error: 'partial provider glitch',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Recovered answer.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    // Error text never pollutes the thinking lane (legacy parity: log-only).
+    expect(assistant?.thinking).toBe('Working…')
+    expect(assistant?.content).toBe('Recovered answer.')
+    expect(assistant?.isStreaming).toBe(false)
+  })
+
+  it('clears streaming flags when SSE ends without a terminal final/error frame', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Halfway…',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Partial answer',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 't1',
+        name: 'search',
+      })
+      // Stream closes abruptly — no final or error event.
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('Partial answer')
+    expect(assistant?.thinking).toBe('Halfway…')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+    expect(assistant?.isToolStreaming).toBe(false)
+    expect(assistant?.toolCalls?.some((t) => t.status === 'error')).toBe(true)
+  })
+
+  it('does not append thinking payload into answer when mislabeled as chunk', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        chunk: 'SHOULD_NOT_APPEND',
+        data: 'real thought',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'ok',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('ok')
+    expect(assistant?.thinking).toBe('real thought')
+  })
+
+  it('TTS audioStreamHandler receives answer text only', async () => {
+    const audioStreamHandler = vi.fn().mockResolvedValue(undefined)
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'secret internal monologue that must not be spoken.',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Hello world.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          voiceSettings: {
+            isVoiceEnabled: true,
+            voiceId: 'voice-1',
+            autoPlayResponses: true,
+          },
+          audioStreamHandler,
+        })
+    })
+    await flushUiBatch()
+
+    expect(audioStreamHandler).toHaveBeenCalled()
+    for (const call of audioStreamHandler.mock.calls) {
+      expect(String(call[0])).not.toContain('secret')
+      expect(String(call[0])).not.toContain('monologue')
+    }
+    expect(audioStreamHandler.mock.calls.some((c) => String(c[0]).includes('Hello'))).toBe(true)
+  })
+
+  it('stopStreaming preserves thinking and aborts the shared controller', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'partial thought',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'partial answer',
+      })
+      // Hold the stream open until Stop aborts.
+      await new Promise((resolve) => {
+        options.signal?.addEventListener('abort', () => resolve(), { once: true })
+        // Also allow test cleanup if abort never fires.
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          abortController,
+        })
+    })
+
+    await flushUiBatch()
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.thinking).toBe('partial thought')
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(abortController.signal.aborted).toBe(true)
+    expect(assistant?.thinking).toBe('partial thought')
+    expect(String(assistant?.content)).toContain('partial answer')
+    expect(String(assistant?.content)).toContain('Response stopped by user')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+  })
+
+  it('does not replace Stop notice with server Client cancelled request error', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'partial answer',
+      })
+      await new Promise((resolve) => {
+        options.signal?.addEventListener(
+          'abort',
+          () => {
+            // Server still emits terminal cancel error while the reader finishes.
+            void options.onEvent({
+              event: 'error',
+              error: 'Client cancelled request',
+            })
+            resolve()
+          },
+          { once: true }
+        )
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          abortController,
+        })
+    })
+
+    await flushUiBatch()
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(String(assistant?.content)).toContain('partial answer')
+    expect(String(assistant?.content)).toContain('Response stopped by user')
+    expect(String(assistant?.content)).not.toContain('Client cancelled request')
+    expect(assistant?.isStreaming).toBe(false)
+  })
+
+  it('leaves thinking undefined when no thinking events arrive', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'just text',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBeUndefined()
+    expect(assistant?.content).toBe('just text')
+  })
+})
+
+describe('useChatStreaming tool lifecycle', () => {
+  let handle: HookHandle
+  let messages: ChatMessage[]
+  let setMessages: React.Dispatch>
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    messages = []
+    setMessages = ((updater: React.SetStateAction) => {
+      messages = typeof updater === 'function' ? updater(messages) : updater
+    }) as React.Dispatch>
+    handle = renderStreamingHook()
+    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+      cb(performance.now())
+      return 1
+    })
+  })
+
+  afterEach(() => {
+    handle.unmount()
+    vi.restoreAllMocks()
+  })
+
+  it('maps tool start/end into keyed chips without touching answer content', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_1',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 'toolu_1',
+        name: 'http_request',
+        status: 'success',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'https://httpbin.org/get',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('https://httpbin.org/get')
+    expect(assistant?.toolCalls).toEqual([
+      {
+        key: 'agent-1:toolu_1',
+        blockId: 'agent-1',
+        id: 'toolu_1',
+        name: 'http_request',
+        displayName: 'Http Request',
+        status: 'success',
+      },
+    ])
+    expect(assistant?.toolCalls?.[0]).not.toHaveProperty('args')
+    expect(assistant?.toolCalls?.[0]).not.toHaveProperty('result')
+    expect(assistant?.isToolStreaming).toBe(false)
+  })
+
+  it('tracks parallel tools and cancels running chips on Stop', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_1',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_2',
+        name: 'function_execute',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 'toolu_2',
+        name: 'function_execute',
+        status: 'success',
+      })
+      await new Promise((resolve) => {
+        options.signal?.addEventListener('abort', () => resolve(), { once: true })
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          abortController,
+        })
+    })
+
+    await flushUiBatch()
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls).toHaveLength(2)
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const tools = messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls
+    expect(tools?.find((t) => t.id === 'toolu_1')?.status).toBe('cancelled')
+    expect(tools?.find((t) => t.id === 'toolu_2')?.status).toBe('success')
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.isToolStreaming).toBe(false)
+  })
+
+  it('settles straggler running tools to success on final', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_open',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls?.[0]?.status).toBe('success')
+  })
+
+  it('settles straggler running tools to error when final reports failure', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_open',
+        name: 'http_request',
+      })
+      // Failed runs can still terminate with `final` carrying success: false.
+      await options.onEvent({
+        event: 'final',
+        data: { success: false, error: 'Workflow failed', output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls?.[0]?.status).toBe('error')
+  })
+})
diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
index be4b0b2e1ae..b7fdb139d67 100644
--- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
+++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
@@ -3,9 +3,29 @@
 import { useRef, useState } from 'react'
 import { createLogger } from '@sim/logger'
 import { generateId } from '@sim/utils/id'
+import {
+  anyToolCallRunning,
+  applyToolCallPhase,
+  settleRunningToolCalls,
+  snapshotToolCalls,
+  toolCallKey,
+} from '@/components/agent-stream/tool-call-lifecycle'
 import { readSSEEvents } from '@/lib/core/utils/sse'
 import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
-import type { ChatFile, ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import {
+  isChatChunkFrame,
+  isChatChunkResetFrame,
+  isChatErrorFrame,
+  isChatFinalFrame,
+  isChatStreamErrorFrame,
+  isChatThinkingFrame,
+  isChatToolFrame,
+} from '@/lib/workflows/streaming/agent-stream-protocol'
+import type {
+  ChatFile,
+  ChatMessage,
+  ChatToolCall,
+} from '@/app/(interfaces)/chat/components/message/message'
 import { CHAT_ERROR_MESSAGES } from '@/app/(interfaces)/chat/constants'
 
 const logger = createLogger('UseChatStreaming')
@@ -64,23 +84,40 @@ export interface StreamingOptions {
   onAudioEnd?: () => void
   audioStreamHandler?: (text: string) => Promise
   outputConfigs?: Array<{ blockId: string; path?: string }>
+  /**
+   * Shared AbortController for fetch + SSE body reads. When provided (preferred),
+   * Stop aborts the in-flight request server-side as well as the reader.
+   */
+  abortController?: AbortController
+}
+
+/** Client-side view of the `final` frame's opaque `data` payload. */
+interface ChatFinalData {
+  success?: boolean
+  error?: string | { message?: string }
+  output?: Record>
 }
 
 export function useChatStreaming() {
   const [isStreamingResponse, setIsStreamingResponse] = useState(false)
   const abortControllerRef = useRef(null)
   const accumulatedTextRef = useRef('')
+  const accumulatedThinkingRef = useRef('')
+  const accumulatedToolCallsRef = useRef([])
   const lastStreamedPositionRef = useRef(0)
   const audioStreamingActiveRef = useRef(false)
-  const lastDisplayedPositionRef = useRef(0) // Track displayed text in synced mode
+  const lastDisplayedPositionRef = useRef(0)
 
   const stopStreaming = (setMessages: React.Dispatch>) => {
     if (abortControllerRef.current) {
-      // Abort the fetch request
       abortControllerRef.current.abort()
       abortControllerRef.current = null
 
       const latestContent = accumulatedTextRef.current
+      const latestThinking = accumulatedThinkingRef.current
+      const latestTools = accumulatedToolCallsRef.current.map((tool) =>
+        tool.status === 'running' ? { ...tool, status: 'cancelled' as const } : tool
+      )
 
       setMessages((prev) => {
         const lastMessage = prev[prev.length - 1]
@@ -92,7 +129,16 @@ export function useChatStreaming() {
 
           return [
             ...prev.slice(0, -1),
-            { ...lastMessage, content: updatedContent, isStreaming: false },
+            {
+              ...lastMessage,
+              content: updatedContent,
+              // Preserve any thinking / tools received before Stop.
+              thinking: latestThinking || lastMessage.thinking,
+              toolCalls: latestTools.length > 0 ? latestTools : lastMessage.toolCalls,
+              isStreaming: false,
+              isThinkingStreaming: false,
+              isToolStreaming: false,
+            },
           ]
         }
 
@@ -101,6 +147,8 @@ export function useChatStreaming() {
 
       setIsStreamingResponse(false)
       accumulatedTextRef.current = ''
+      accumulatedThinkingRef.current = ''
+      accumulatedToolCallsRef.current = []
       lastStreamedPositionRef.current = 0
       lastDisplayedPositionRef.current = 0
       audioStreamingActiveRef.current = false
@@ -112,15 +160,18 @@ export function useChatStreaming() {
     setMessages: React.Dispatch>,
     setIsLoading: React.Dispatch>,
     scrollToBottom: () => void,
-    userHasScrolled?: boolean,
     streamingOptions?: StreamingOptions
   ) => {
     logger.info('[useChatStreaming] handleStreamedResponse called')
-    // Set streaming state
     setIsStreamingResponse(true)
-    abortControllerRef.current = new AbortController()
 
-    // Check if we should stream audio
+    // Prefer a shared controller from the caller (fetch + reader). Otherwise create one.
+    if (streamingOptions?.abortController) {
+      abortControllerRef.current = streamingOptions.abortController
+    } else if (!abortControllerRef.current) {
+      abortControllerRef.current = new AbortController()
+    }
+
     const shouldPlayAudio =
       streamingOptions?.voiceSettings?.isVoiceEnabled &&
       streamingOptions?.voiceSettings?.autoPlayResponses &&
@@ -132,8 +183,28 @@ export function useChatStreaming() {
       return
     }
 
+    /**
+     * Answer text tracked per block so a `chunk_reset` (dual-gated streams:
+     * a live-streamed turn resolved to tool calls) can clear one block's
+     * contribution. `accumulatedText` is re-derived on every mutation —
+     * cross-block separators arrive baked into the chunks.
+     */
+    const blockTextOrder: string[] = []
+    const blockTextSegments = new Map()
     let accumulatedText = ''
+    const recomputeAccumulatedText = () => {
+      accumulatedText = blockTextOrder.map((id) => blockTextSegments.get(id) ?? '').join('')
+      accumulatedTextRef.current = accumulatedText
+    }
+    let accumulatedThinking = ''
+    let isThinkingStreaming = false
     let lastAudioPosition = 0
+    const toolCallsMap = new Map()
+    const toolCallOrder: string[] = []
+
+    const syncToolCallsRef = () => {
+      accumulatedToolCallsRef.current = snapshotToolCalls(toolCallOrder, toolCallsMap) ?? []
+    }
 
     const messageIdMap = new Map()
     const messageId = generateId()
@@ -156,14 +227,29 @@ export function useChatStreaming() {
       if (!uiDirty) return
       uiDirty = false
       lastUIFlush = performance.now()
-      const snapshot = accumulatedText
+      const contentSnapshot = accumulatedText
+      const thinkingSnapshot = accumulatedThinking
+      const thinkingStreamingSnapshot = isThinkingStreaming
+      const toolCallsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
+      const toolStreamingSnapshot = anyToolCallRunning(toolCallsMap)
       setMessages((prev) =>
         prev.map((msg) => {
           if (msg.id !== messageId) return msg
           if (!msg.isStreaming) return msg
-          return { ...msg, content: snapshot }
+          return {
+            ...msg,
+            content: contentSnapshot,
+            thinking: thinkingSnapshot || undefined,
+            isThinkingStreaming: thinkingStreamingSnapshot,
+            toolCalls: toolCallsSnapshot,
+            isToolStreaming: toolStreamingSnapshot,
+          }
         })
       )
+      // Caller supplies a stick-to-bottom-aware scroller (no-ops if user scrolled away).
+      requestAnimationFrame(() => {
+        scrollToBottom()
+      })
     }
 
     const scheduleUIFlush = () => {
@@ -192,35 +278,59 @@ export function useChatStreaming() {
     setIsLoading(false)
 
     let terminated = false
+    // Capture before Stop nulls abortControllerRef; needed when the reader
+    // resolves on abort instead of throwing AbortError.
+    const streamAbortSignal = abortControllerRef.current!.signal
 
     try {
-      await readSSEEvents<{
-        blockId?: string
-        chunk?: string
-        event?: string
-        error?: string
-        data?: {
-          success: boolean
-          error?: string | { message?: string }
-          output?: Record>
-        }
-      }>(response.body, {
-        signal: abortControllerRef.current.signal,
+      await readSSEEvents>(response.body, {
+        signal: streamAbortSignal,
         onParseError: (_data, parseError) => {
           logger.error('Error parsing stream data:', parseError)
         },
         onEvent: async (json) => {
-          const { blockId, chunk: contentChunk, event: eventType } = json
+          if (isChatErrorFrame(json)) {
+            // User Stop aborts the fetch; the server often still emits a terminal
+            // `{ event: 'error', error: 'Client cancelled request' }` before the
+            // SSE reader finishes. Do not overwrite the stop notice.
+            if (streamAbortSignal.aborted) {
+              settleRunningToolCalls(toolCallsMap, 'cancelled')
+              syncToolCallsRef()
+              const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
+              setMessages((prev) =>
+                prev.map((msg) =>
+                  msg.id === messageId
+                    ? {
+                        ...msg,
+                        isStreaming: false,
+                        isThinkingStreaming: false,
+                        isToolStreaming: false,
+                        thinking: accumulatedThinking || msg.thinking,
+                        toolCalls: toolsSnapshot ?? msg.toolCalls,
+                      }
+                    : msg
+                )
+              )
+              setIsLoading(false)
+              terminated = true
+              return true
+            }
 
-          if (eventType === 'error' || json.event === 'error') {
             const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR
+            settleRunningToolCalls(toolCallsMap, 'error')
+            syncToolCallsRef()
+            const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
             setMessages((prev) =>
               prev.map((msg) =>
                 msg.id === messageId
                   ? {
                       ...msg,
                       content: errorMessage,
+                      thinking: accumulatedThinking || msg.thinking,
+                      toolCalls: toolsSnapshot ?? msg.toolCalls,
                       isStreaming: false,
+                      isThinkingStreaming: false,
+                      isToolStreaming: false,
                       type: 'assistant' as const,
                     }
                   : msg
@@ -231,9 +341,70 @@ export function useChatStreaming() {
             return true
           }
 
-          if (eventType === 'final' && json.data) {
+          if (isChatStreamErrorFrame(json)) {
+            // Non-terminal mid-block read issue: keep streaming. The legacy
+            // client ignored these frames; log only — never repurpose the
+            // thinking lane for error text.
+            logger.warn('[useChatStreaming] Non-terminal stream_error', {
+              blockId: json.blockId,
+              error: json.error || 'A streaming error occurred',
+            })
+            return false
+          }
+
+          if (isChatThinkingFrame(json)) {
+            if (!messageIdMap.has(json.blockId)) {
+              messageIdMap.set(json.blockId, messageId)
+            }
+            accumulatedThinking += json.data
+            accumulatedThinkingRef.current = accumulatedThinking
+            isThinkingStreaming = true
+            uiDirty = true
+            scheduleUIFlush()
+            return false
+          }
+
+          if (isChatToolFrame(json)) {
+            const { blockId } = json
+            if (!messageIdMap.has(blockId)) {
+              messageIdMap.set(blockId, messageId)
+            }
+            // Tools starting means the turn's thinking phase is over — settle
+            // the thinking chrome (it re-opens if more thinking streams later).
+            if (json.phase === 'start' && isThinkingStreaming) {
+              isThinkingStreaming = false
+            }
+            applyToolCallPhase(
+              toolCallsMap,
+              toolCallOrder,
+              {
+                key: toolCallKey(blockId, json.id),
+                id: json.id,
+                name: json.name,
+                phase: json.phase,
+                status: json.status,
+              },
+              (tool): ChatToolCall => ({
+                ...tool,
+                blockId,
+                displayName: tool.displayName ?? tool.name,
+              })
+            )
+            syncToolCallsRef()
+            uiDirty = true
+            scheduleUIFlush()
+            return false
+          }
+
+          if (isChatFinalFrame(json)) {
             flushUI()
-            const finalData = json.data
+            const finalData = json.data as ChatFinalData
+            isThinkingStreaming = false
+            // A failed run can still terminate with `final` (success: false) —
+            // straggler running chips must not settle green in that case.
+            settleRunningToolCalls(toolCallsMap, finalData.success === false ? 'error' : 'success')
+            syncToolCallsRef()
+            const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
 
             const outputConfigs = streamingOptions?.outputConfigs
             const formattedOutputs: string[] = []
@@ -358,7 +529,11 @@ export function useChatStreaming() {
                   ? {
                       ...msg,
                       isStreaming: false,
+                      isThinkingStreaming: false,
+                      isToolStreaming: false,
                       content: finalContent ?? msg.content,
+                      thinking: accumulatedThinking || msg.thinking,
+                      toolCalls: toolsSnapshot ?? msg.toolCalls,
                       files: extractedFiles.length > 0 ? extractedFiles : undefined,
                     }
                   : msg
@@ -366,6 +541,8 @@ export function useChatStreaming() {
             )
 
             accumulatedTextRef.current = ''
+            accumulatedThinkingRef.current = ''
+            accumulatedToolCallsRef.current = []
             lastStreamedPositionRef.current = 0
             lastDisplayedPositionRef.current = 0
             audioStreamingActiveRef.current = false
@@ -374,13 +551,46 @@ export function useChatStreaming() {
             return true
           }
 
-          if (blockId && contentChunk) {
+          if (isChatChunkResetFrame(json)) {
+            // The block's live-streamed text belonged to an intermediate turn
+            // (tool calls follow); drop it — the final turn re-streams after.
+            // Remove the block from the order too: its re-streamed text
+            // re-registers at the end, keeping render order = arrival order
+            // (the server re-computes the cross-block separator on re-stream).
+            const { blockId } = json
+            if (blockTextSegments.has(blockId)) {
+              blockTextSegments.delete(blockId)
+              const orderIndex = blockTextOrder.indexOf(blockId)
+              if (orderIndex !== -1) {
+                blockTextOrder.splice(orderIndex, 1)
+              }
+              recomputeAccumulatedText()
+              // Spoken audio cannot be unplayed; clamp so slicing stays valid.
+              lastAudioPosition = Math.min(lastAudioPosition, accumulatedText.length)
+              uiDirty = true
+              scheduleUIFlush()
+            }
+            return false
+          }
+
+          // Answer text only — never append thinking/tool/unknown chunk frames blindly.
+          if (isChatChunkFrame(json)) {
+            const { blockId, chunk: contentChunk } = json
             if (!messageIdMap.has(blockId)) {
               messageIdMap.set(blockId, messageId)
             }
 
-            accumulatedText += contentChunk
-            accumulatedTextRef.current = accumulatedText
+            // First answer chunk settles thinking chrome (still visible, no longer “live”).
+            if (isThinkingStreaming) {
+              isThinkingStreaming = false
+            }
+
+            if (!blockTextSegments.has(blockId)) {
+              blockTextOrder.push(blockId)
+              blockTextSegments.set(blockId, '')
+            }
+            blockTextSegments.set(blockId, blockTextSegments.get(blockId)! + contentChunk)
+            recomputeAccumulatedText()
             logger.debug('[useChatStreaming] Received chunk', {
               blockId,
               chunkLength: contentChunk.length,
@@ -416,17 +626,47 @@ export function useChatStreaming() {
                 }
               }
             }
-          } else if (blockId && eventType === 'end') {
-            setMessages((prev) =>
-              prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
-            )
           }
         },
       })
 
       if (!terminated) {
         flushUI()
+        // Stream closed without a terminal final/error frame (abrupt disconnect,
+        // or only non-terminal stream_error). Clear live chrome so the UI does not
+        // stay stuck in a streaming/loading state.
+        const wasAborted = streamAbortSignal.aborted
+        settleRunningToolCalls(toolCallsMap, wasAborted ? 'cancelled' : 'error')
+        syncToolCallsRef()
+        isThinkingStreaming = false
+        const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
+        setMessages((prev) =>
+          prev.map((msg) => {
+            if (msg.id !== messageId) return msg
+            // stopStreaming already wrote the stop notice into content; do not clobber it.
+            if (wasAborted) {
+              return {
+                ...msg,
+                isStreaming: false,
+                isThinkingStreaming: false,
+                isToolStreaming: false,
+                thinking: accumulatedThinking || msg.thinking,
+                toolCalls: toolsSnapshot ?? msg.toolCalls,
+              }
+            }
+            return {
+              ...msg,
+              isStreaming: false,
+              isThinkingStreaming: false,
+              isToolStreaming: false,
+              content: accumulatedText || msg.content,
+              thinking: accumulatedThinking || msg.thinking,
+              toolCalls: toolsSnapshot ?? msg.toolCalls,
+            }
+          })
+        )
         if (
+          !wasAborted &&
           shouldPlayAudio &&
           streamingOptions?.audioStreamHandler &&
           accumulatedText.length > lastAudioPosition
@@ -442,10 +682,31 @@ export function useChatStreaming() {
         }
       }
     } catch (error) {
-      logger.error('Error processing stream:', error)
+      // Stop / timeout abort the shared fetch controller; body read then throws AbortError.
+      // Match chat.tsx + use-audio-streaming: expected cancel, not a hard failure.
+      if (error instanceof Error && error.name === 'AbortError') {
+        logger.info('Stream aborted by user or timeout')
+        settleRunningToolCalls(toolCallsMap, 'cancelled')
+      } else {
+        logger.error('Error processing stream:', error)
+        settleRunningToolCalls(toolCallsMap, 'error')
+      }
+      syncToolCallsRef()
       flushUI()
+      const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
       setMessages((prev) =>
-        prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
+        prev.map((msg) =>
+          msg.id === messageId
+            ? {
+                ...msg,
+                isStreaming: false,
+                isThinkingStreaming: false,
+                isToolStreaming: false,
+                thinking: accumulatedThinking || msg.thinking,
+                toolCalls: toolsSnapshot ?? msg.toolCalls,
+              }
+            : msg
+        )
       )
     } finally {
       if (uiRAF !== null) cancelAnimationFrame(uiRAF)
@@ -453,11 +714,10 @@ export function useChatStreaming() {
       setIsStreamingResponse(false)
       abortControllerRef.current = null
 
-      if (!userHasScrolled) {
-        setTimeout(() => {
-          scrollToBottom()
-        }, 300)
-      }
+      // Stick-to-bottom-aware; no-ops if the user scrolled away mid-stream.
+      setTimeout(() => {
+        scrollToBottom()
+      }, 300)
 
       if (shouldPlayAudio) {
         streamingOptions?.onAudioEnd?.()
diff --git a/apps/sim/app/(landing)/comparisons/[provider]/page.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx
index 2209e94a97c..d61c9b288a1 100644
--- a/apps/sim/app/(landing)/comparisons/[provider]/page.tsx
+++ b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx
@@ -177,7 +177,16 @@ export default async function ComparisonProviderPage({
               Sim is the open-source AI workspace where teams build, deploy, and manage AI agents
               visually, conversationally, or with code. Here is how Sim compares to{' '}
               {competitor.name} on platform architecture, AI capabilities, integrations, pricing,
-              security, and support. Every fact below is sourced and dated.
+              security, and support. Every fact below is sourced and dated, last verified{' '}
+              
+              .
             

Sim is an open-source AI workspace for building, deploying, and managing AI agents. diff --git a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx index e6777a573c1..3e96a751aa0 100644 --- a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx +++ b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx @@ -7,6 +7,16 @@ import { BackLink } from '@/app/(landing)/components/back-link' import { JsonLd } from '@/app/(landing)/components/json-ld' import { ShareButton } from '@/app/(landing)/components/share-button' +/** Renders an ISO date as "Jul 1, 2026". Pinned to UTC so the day matches the frontmatter date in every reader's timezone. */ +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) +} + interface ContentPostPageProps { /** Route base path, e.g. `/blog` or `/library`. */ basePath: string @@ -32,6 +42,8 @@ export function ContentPostPage({ shareUrl, }: ContentPostPageProps) { const Article = post.Content + const modifiedIso = post.updated ?? post.date + const showUpdated = modifiedIso.slice(0, 10) !== post.date.slice(0, 10) return (

@@ -73,20 +85,32 @@ export function ContentPostPage({ {post.description}

-
- - +
+
+ + {showUpdated ? ( + <> + + + + ) : ( + + )} +
{(post.authors || [post.author]).map((a) => (
@@ -151,12 +175,7 @@ export function ContentPostPage({
- {new Date(p.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - timeZone: 'UTC', - })} + {formatDate(p.date)}

{p.title} diff --git a/apps/sim/app/(landing)/components/features/features.tsx b/apps/sim/app/(landing)/components/features/features.tsx index fe8fb8351d4..d7fafca3867 100644 --- a/apps/sim/app/(landing)/components/features/features.tsx +++ b/apps/sim/app/(landing)/components/features/features.tsx @@ -67,7 +67,7 @@ export function Features() { title='Build agents that solve real problems.' description="Wire blocks, models, and integrations into agent logic on Sim's visual builder, from one agent to many working in parallel." href='/workflows' - linkLabel='Explore the workflow builder' + linkLabel='Explore the AI workflow builder' > diff --git a/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx b/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx index d06e6ca7a88..c2e9b566956 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx @@ -6,12 +6,6 @@ import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout' interface LandingHeroHeaderProps { description: string - /** - * Optional second paragraph beneath the description - a self-contained - * definition of the page's subject, kept quotable for answer engines (GEO). - * Omitted by the homepage, so its hero renders unchanged. - */ - definition?: string eyebrow?: ReactNode heading: ReactNode headingId: string @@ -23,7 +17,6 @@ interface LandingHeroHeaderProps { */ export function LandingHeroHeader({ description, - definition, eyebrow, heading, headingId, @@ -44,12 +37,6 @@ export function LandingHeroHeader({ {description}

- {definition ? ( -

- {definition} -

- ) : null} -
diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx index f28d618087c..8036e4b082c 100644 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ b/apps/sim/app/(landing)/components/hero/hero.tsx @@ -90,8 +90,7 @@ export function Hero() { building and managing AI agents. } - description='Sim is an AI agent and workflow builder for teams creating agents that automate real work. Design workflows visually, describe what you need in natural language, or use code for complete control.' - definition='Connect your agents to 1,000+ integrations and every major LLM, then deploy, monitor, and improve them from one collaborative, open-source workspace.' + description='Open source, with 1,000+ integrations and every major LLM. Build, deploy, and manage agents visually, conversationally, or with code.' />
({ mockGetSession: vi.fn(), mockRegisterSSOProvider: vi.fn(), + mockUpdateSSOProvider: vi.fn(), mockHasSSOAccess: vi.fn(), mockValidateUrlWithDNS: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), @@ -45,14 +47,19 @@ function queueProviders(rows: Array>) { vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, - auth: { api: { registerSSOProvider: mockRegisterSSOProvider } }, + auth: { + api: { + registerSSOProvider: mockRegisterSSOProvider, + updateSSOProvider: mockUpdateSSOProvider, + }, + }, })) vi.mock('@/lib/billing', () => ({ hasSSOAccess: mockHasSSOAccess, })) -vi.mock('@/lib/auth/sso/domain', () => ({ +vi.mock('@sim/utils/sso-domain', () => ({ normalizeSSODomain: (input: unknown): string | null => { if (typeof input !== 'string') return null const value = input.trim().toLowerCase() @@ -93,7 +100,17 @@ describe('POST /api/auth/sso/register', () => { mockHasSSOAccess.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test')) - mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) + mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' }) + mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) + // Default: the org has already verified the domain, so the ownership gate + // passes and each test exercises the logic beyond it. The gate is checked + // three times for a successful org-scoped registration (fail-fast entry + + // authoritative re-check before the write + compensating re-check after the + // write), so queue three rows. Gate-specific tests reset the queue to assert + // the unverified paths. + queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) }) afterAll(() => { @@ -122,6 +139,43 @@ describe('POST /api/auth/sso/register', () => { expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) + it('rejects configuring org SSO for a domain the org has not verified', async () => { + resetDbChainMock() + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoDomain, []) // no verified sso_domain row + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + const json = await res.json() + expect(res.status).toBe(403) + expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + }) + + it('re-checks verification before the write and 403s if it was revoked mid-registration', async () => { + resetDbChainMock() + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified + queueTableRows(schemaMock.ssoDomain, []) // re-check before write: revoked + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + const json = await res.json() + expect(res.status).toBe(403) + expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + }) + + it('rolls back the newly-created provider if verification is revoked after the write', async () => { + resetDbChainMock() + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified + queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified + queueTableRows(schemaMock.ssoDomain, []) // post-write compensating check: revoked + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + const json = await res.json() + expect(res.status).toBe(403) + expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') + expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created… + expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back + }) + it('rejects a domain already registered by another organization', async () => { queueMembers([{ organizationId: 'org-attacker', role: 'owner' }]) queueProviders([{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]) @@ -152,6 +206,30 @@ describe('POST /api/auth/sso/register', () => { expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) + it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => { + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + await POST( + request({ ...OIDC_BODY, orgId: 'org1', mapping: { id: 'oid', email: 'upn', name: 'name' } }) + ) + expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) + const sent = mockRegisterSSOProvider.mock.calls[0][0].body + expect(sent.mapping).toBeUndefined() // not passed at the top level (silently ignored there) + expect(sent.oidcConfig.mapping).toMatchObject({ id: 'oid', email: 'upn', name: 'name' }) + }) + + it('routes an edit of an existing owned provider through updateSSOProvider', async () => { + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #1 → no conflict + queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #2 → no conflict + queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.message).toContain('updated') + expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1) + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + }) + it('allows the owning tenant to update its own provider for the same domain', async () => { queueMembers([{ organizationId: 'org1', role: 'owner' }]) queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]) diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index d826c4641fb..23f872c5077 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -1,12 +1,12 @@ -import { db, member, ssoProvider } from '@sim/db' +import { db, member, ssoDomain, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, sql } from 'drizzle-orm' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { ssoRegistrationContract } from '@/lib/api/contracts/auth' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth' -import { normalizeSSODomain } from '@/lib/auth/sso/domain' import { hasSSOAccess } from '@/lib/billing' import { env } from '@/lib/core/config/env' import { @@ -117,9 +117,48 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const domain = normalizeSSODomain(body.domain) if (!domain) { - return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 }) + return NextResponse.json( + { error: 'Enter a valid domain, for example acme.com' }, + { status: 400 } + ) } + // Security gate: configuring org SSO for a domain requires the org to have + // proven ownership of it (DNS TXT verification). Without this, the old + // first-come claim let any org wire another company's domain to their own + // IdP — an account-takeover primitive. Existing domains were grandfathered + // as verified by migration 0266, so live tenants are unaffected. Personal + // (org-less) SSO is not gated. + const isOrgDomainVerified = async (): Promise => { + if (!orgId) return true + const [verified] = await db + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where( + and( + eq(ssoDomain.organizationId, orgId), + eq(ssoDomain.domain, domain), + eq(ssoDomain.status, 'verified') + ) + ) + .limit(1) + return Boolean(verified) + } + + const domainNotVerifiedResponse = () => + NextResponse.json( + { + error: `Verify ownership of ${domain} under Settings → Verified domains before configuring SSO for it.`, + code: 'SSO_DOMAIN_NOT_VERIFIED', + }, + { status: 403 } + ) + + // Fail fast before the expensive OIDC discovery. Re-checked immediately + // before the provider write below to close the TOCTOU window (the verified + // row could be removed while discovery is in flight). + if (!(await isOrgDomainVerified())) return domainNotVerifiedResponse() + const isOwnedByCaller = (provider: { userId: string | null organizationId: string | null @@ -166,7 +205,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { providerId, issuer, domain, - mapping, ...(orgId ? { organizationId: orgId } : {}), } @@ -187,7 +225,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (rawClientSecret === REDACTED_MARKER) { const ownerClause = orgId ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId)) - : and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id)) + : and( + eq(ssoProvider.providerId, providerId), + eq(ssoProvider.userId, session.user.id), + isNull(ssoProvider.organizationId) + ) const [existing] = await db .select({ oidcConfig: ssoProvider.oidcConfig }) .from(ssoProvider) @@ -378,6 +420,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } oidcConfig.skipDiscovery = true + // Better Auth reads the attribute mapping from oidcConfig.mapping, not a + // top-level field — nesting it here is what makes a custom mapping apply. + if (mapping) oidcConfig.mapping = mapping providerConfig.oidcConfig = oidcConfig } else if (providerType === 'saml') { const { @@ -459,6 +504,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm if (identifierFormat) samlConfig.identifierFormat = identifierFormat + // Better Auth reads the attribute mapping from samlConfig.mapping. + if (mapping) samlConfig.mapping = mapping providerConfig.samlConfig = samlConfig } @@ -499,11 +546,104 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return domainConflictResponse() } + // Authoritative verification re-check: the verified row could have been + // removed during OIDC discovery. Re-checking here (not just at handler + // entry) ensures ownership still holds at the moment of the write. + if (!(await isOrgDomainVerified())) { + logger.warn( + 'Rejected SSO registration: domain verification was revoked during registration', + { + domain, + orgId, + userId: session.user.id, + } + ) + return domainNotVerifiedResponse() + } + + // Better Auth's registerSSOProvider is create-only (it throws on an existing + // providerId). If the caller already owns a provider with this id, route the + // edit through updateSSOProvider so re-saving an SSO config works instead of + // failing. The verification gate above already ran against the target domain, + // so an edit that moves SSO to an unverified domain is still blocked. + // The personal branch MUST require a null org: org providers store + // userId = their creator, so without it an org admin could send a + // personal-mode request (which skips the membership check and the + // verification gate) yet still match — and then update — their org's + // provider, moving it to an unverified domain. Mirrors isOwnedByCaller. + const ownerClause = orgId + ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId)) + : and( + eq(ssoProvider.providerId, providerId), + eq(ssoProvider.userId, session.user.id), + isNull(ssoProvider.organizationId) + ) + const [existingOwnedProvider] = await db + .select({ id: ssoProvider.id }) + .from(ssoProvider) + .where(ownerClause) + .limit(1) + + if (existingOwnedProvider) { + await auth.api.updateSSOProvider({ + body: { + providerId, + issuer, + domain, + ...(providerConfig.oidcConfig ? { oidcConfig: providerConfig.oidcConfig } : {}), + ...(providerConfig.samlConfig ? { samlConfig: providerConfig.samlConfig } : {}), + }, + headers, + }) + logger.info('SSO provider updated successfully', { providerId, providerType, domain }) + return NextResponse.json({ + success: true, + providerId, + providerType, + message: `${providerType.toUpperCase()} provider updated successfully`, + }) + } + const registration = await auth.api.registerSSOProvider({ body: providerConfig, headers, }) + // Close the residual TOCTOU between the re-check above and Better Auth + // persisting the provider: the verified sso_domain row could be removed in + // that window. registerSSOProvider is create-only (it throws if the + // providerId already exists), so a successful call always created a brand-new + // row — we roll it back by its primary-key `id` (not the logical providerId, + // which a concurrent delete+recreate could point at a different row). Personal + // SSO is not gated, so this only runs for org-scoped registration. + if (orgId && !(await isOrgDomainVerified())) { + // registerSSOProvider spreads the created row's `id` at runtime, but the + // typed return omits it — read it defensively and only delete when it's a + // real id, so a future shape change can't turn the rollback into a silent + // no-op that leaves a provider on an unverified domain. + // double-cast-allowed: Better Auth's return type omits the runtime `id` + const createdRowId = (registration as unknown as { id?: unknown }).id + if (typeof createdRowId === 'string' && createdRowId.length > 0) { + await db + .delete(ssoProvider) + .where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId))) + logger.warn('Rolled back SSO provider: domain verification revoked mid-registration', { + domain, + orgId, + providerId: registration.providerId, + userId: session.user.id, + }) + } else { + logger.error('Could not roll back SSO provider: registration returned no usable id', { + domain, + orgId, + providerId: registration.providerId, + userId: session.user.id, + }) + } + return domainNotVerifiedResponse() + } + logger.info('SSO provider registered successfully', { providerId, providerType, @@ -517,16 +657,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { message: `${providerType.toUpperCase()} provider registered successfully`, }) } catch (error) { - logger.error('Failed to register SSO provider', { + logger.error('Failed to save SSO provider', { error, errorMessage: getErrorMessage(error, 'Unknown error'), errorStack: error instanceof Error ? error.stack : undefined, errorDetails: JSON.stringify(error), }) + // Surface Better Auth's own APIError (e.g. a 409 when identity fields change + // while linked accounts exist, or a 404) with its status and message instead + // of a generic 500, so the client shows an actionable error. + const apiError = error as { statusCode?: unknown; body?: { message?: unknown } } + if (typeof apiError.statusCode === 'number' && typeof apiError.body?.message === 'string') { + return NextResponse.json({ error: apiError.body.message }, { status: apiError.statusCode }) + } + return NextResponse.json( { - error: 'Failed to register SSO provider', + error: 'Failed to save the SSO provider', details: getErrorMessage(error, 'Unknown error'), }, { status: 500 } diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 248fcfa84ab..4ad1a4f0509 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -160,6 +160,8 @@ export const PUT = withRouteHandler( authType: chat.authType, password: chat.password, outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, }) .from(chat) .where( @@ -209,6 +211,8 @@ export const PUT = withRouteHandler( customizations: deployment.customizations, authType: deployment.authType, outputConfigs: deployment.outputConfigs, + includeThinking: deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false, }) setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 82f3def0220..33a9bdd4a77 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -36,6 +36,7 @@ function createMockNextRequest( method, headers: headersObj, nextUrl: parsedUrl, + signal: AbortSignal.timeout(60_000), cookies: { get: vi.fn().mockReturnValue(undefined), }, @@ -106,6 +107,7 @@ vi.mock('@/lib/uploads', () => ({ vi.mock('@/lib/workflows/streaming/streaming', () => ({ createStreamingResponse: vi.fn().mockImplementation(async () => createMockStream()), + agentStreamProtocolResponseHeaders: vi.fn().mockReturnValue({}), })) vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ @@ -124,6 +126,7 @@ vi.mock('@/lib/core/utils/sse', () => ({ vi.mock('@/lib/core/security/encryption', () => encryptionMock) import { preprocessExecution } from '@/lib/execution/preprocessing' +import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' import { GET, POST } from '@/app/api/chat/[identifier]/route' @@ -142,6 +145,8 @@ describe('Chat Identifier API Route', () => { primaryColor: '#000000', }, outputConfigs: [{ blockId: 'block-1', path: 'output' }], + includeThinking: false, + includeToolCalls: null, }, ] @@ -235,6 +240,7 @@ describe('Chat Identifier API Route', () => { expect(data).toHaveProperty('description', 'Test chat description') expect(data).toHaveProperty('customizations') expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat') + expect(data).toHaveProperty('includeToolCalls', false) }) it('should return 404 for non-existent identifier', async () => { @@ -395,14 +401,159 @@ describe('Chat Identifier API Route', () => { expect(createStreamingResponse).toHaveBeenCalledWith( expect.objectContaining({ executeFn: expect.any(Function), + requestSignal: expect.any(AbortSignal), + requestHeaders: expect.anything(), streamConfig: expect.objectContaining({ isSecureMode: true, workflowTriggerType: 'chat', + includeThinking: false, + includeToolCalls: false, }), }) ) }, 10000) + it('preserves the legacy tool policy when includeToolCalls is null', async () => { + const thinkingChatResult = [ + { ...mockChatResult[0], includeThinking: true, includeToolCalls: null }, + ] + dbChainMockFns.select.mockImplementation((fields: Record) => { + if (fields && fields.isDeployed !== undefined) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(mockWorkflowResult), + }), + }), + } + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(thinkingChatResult), + }), + }), + } + }) + + const req = createMockNextRequest( + 'POST', + { input: 'Hello world' }, + { 'X-Sim-Stream-Protocol': 'agent-events-v1' } + ) + const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + expect(response.status).toBe(200) + + const options = vi.mocked(createStreamingResponse).mock.calls[0][0] + expect(options.streamConfig).toMatchObject({ + includeThinking: true, + includeToolCalls: true, + }) + + await options.executeFn({ + onStream: vi.fn(), + onBlockComplete: vi.fn(), + abortSignal: new AbortController().signal, + }) + const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4] + expect(executeOptions).toMatchObject({ + includeThinking: true, + includeToolCalls: true, + agentEvents: true, + }) + }, 10000) + + it('enables agent events for an independent tool-only policy', async () => { + const toolChatResult = [ + { ...mockChatResult[0], includeThinking: false, includeToolCalls: true }, + ] + dbChainMockFns.select.mockImplementation((fields: Record) => { + if (fields && fields.isDeployed !== undefined) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(mockWorkflowResult), + }), + }), + } + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(toolChatResult), + }), + }), + } + }) + + const req = createMockNextRequest( + 'POST', + { input: 'Hello world' }, + { 'X-Sim-Stream-Protocol': 'agent-events-v1' } + ) + const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + expect(response.status).toBe(200) + + const options = vi.mocked(createStreamingResponse).mock.calls[0][0] + expect(options.streamConfig).toMatchObject({ + includeThinking: false, + includeToolCalls: true, + }) + + await options.executeFn({ + onStream: vi.fn(), + onBlockComplete: vi.fn(), + abortSignal: new AbortController().signal, + }) + const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4] + expect(executeOptions).toMatchObject({ + includeThinking: false, + includeToolCalls: true, + agentEvents: true, + }) + }, 10000) + + it('keeps agent events off when the protocol header is missing, even with policy on', async () => { + const thinkingChatResult = [ + { ...mockChatResult[0], includeThinking: true, includeToolCalls: false }, + ] + dbChainMockFns.select.mockImplementation((fields: Record) => { + if (fields && fields.isDeployed !== undefined) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(mockWorkflowResult), + }), + }), + } + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(thinkingChatResult), + }), + }), + } + }) + + const req = createMockNextRequest('POST', { input: 'Hello world' }) + const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + expect(response.status).toBe(200) + + const options = vi.mocked(createStreamingResponse).mock.calls[0][0] + await options.executeFn({ + onStream: vi.fn(), + onBlockComplete: vi.fn(), + abortSignal: new AbortController().signal, + }) + const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4] + expect(executeOptions).toMatchObject({ + includeThinking: true, + includeToolCalls: false, + agentEvents: false, + }) + }, 10000) + it('should handle streaming response body correctly', async () => { const req = createMockNextRequest('POST', { input: 'Hello world' }) const params = Promise.resolve({ identifier: 'test-chat' }) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 578be93c189..7402147449f 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -27,6 +27,8 @@ interface ChatConfigSource { customizations: unknown authType: string | null outputConfigs: unknown + includeThinking?: boolean | null + includeToolCalls?: boolean | null } function toChatConfigResponse(deployment: ChatConfigSource) { @@ -37,6 +39,8 @@ function toChatConfigResponse(deployment: ChatConfigSource) { customizations: deployment.customizations, authType: deployment.authType, outputConfigs: deployment.outputConfigs, + includeThinking: deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false, } } @@ -80,6 +84,8 @@ export const POST = withRouteHandler( password: chat.password, allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, }) .from(chat) .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) @@ -213,7 +219,12 @@ export const POST = withRouteHandler( } } - const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming') + const { createStreamingResponse, agentStreamProtocolResponseHeaders } = await import( + '@/lib/workflows/streaming/streaming' + ) + const { shouldEmitAgentStreamEvents } = await import( + '@/lib/workflows/streaming/agent-stream-protocol' + ) const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow') const { SSE_HEADERS } = await import('@/lib/core/utils/sse') @@ -269,17 +280,28 @@ export const POST = withRouteHandler( variables: (workflowRecord?.variables as Record) ?? undefined, } + const includeThinking = deployment.includeThinking ?? false + const includeToolCalls = deployment.includeToolCalls ?? includeThinking + const agentEvents = shouldEmitAgentStreamEvents({ + includeThinking, + includeToolCalls, + requestHeaders: request.headers, + }) const stream = await createStreamingResponse({ requestId, streamConfig: { selectedOutputs, isSecureMode: true, workflowTriggerType: 'chat', + includeThinking, + includeToolCalls, }, executionId, workspaceId, workflowId: deployment.workflowId, userId: resolvedActorUserId, + requestSignal: request.signal, + requestHeaders: request.headers, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => executeWorkflow( workflowForExecution, @@ -297,6 +319,9 @@ export const POST = withRouteHandler( abortSignal, executionMode: 'stream', billingAttribution, + includeThinking, + includeToolCalls, + agentEvents, }, executionId ), @@ -304,7 +329,10 @@ export const POST = withRouteHandler( const streamResponse = new NextResponse(stream, { status: 200, - headers: SSE_HEADERS, + headers: { + ...SSE_HEADERS, + ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), + }, }) return streamResponse } catch (error: any) { @@ -341,6 +369,8 @@ export const GET = withRouteHandler( password: chat.password, allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, }) .from(chat) .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index bedfbece4a6..4b021ae02f9 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -144,6 +144,8 @@ describe('Chat Edit API Route', () => { description: 'A test chat', password: 'encrypted-password', customizations: { primaryColor: '#000000' }, + includeThinking: true, + includeToolCalls: null, } mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) @@ -158,6 +160,7 @@ describe('Chat Edit API Route', () => { expect(data.title).toBe('Test Chat') expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat') expect(data.hasPassword).toBe(true) + expect(data.includeToolCalls).toBe(true) }) }) @@ -206,6 +209,8 @@ describe('Chat Edit API Route', () => { title: 'Test Chat', authType: 'public', workflowId: 'workflow-123', + includeThinking: true, + includeToolCalls: null, } mockCheckChatAccess.mockResolvedValue({ @@ -222,12 +227,47 @@ describe('Chat Edit API Route', () => { expect(response.status).toBe(200) expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ includeToolCalls: true }) + ) const data = await response.json() expect(data.id).toBe('chat-123') expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat') expect(data.message).toBe('Chat deployment updated successfully') }) + it('turns grandfathered tool calls off when the same update disables thinking', async () => { + // Before includeToolCalls existed, includeThinking gated tool frames too. + // Resolving the fallback against the stored value would materialize the + // stale `true` and silently leave tool frames on. + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) + + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { + id: 'chat-123', + identifier: 'test-chat', + title: 'Test Chat', + authType: 'public', + workflowId: 'workflow-123', + includeThinking: true, + includeToolCalls: null, + }, + workspaceId: 'workspace-123', + }) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ includeThinking: false }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ includeThinking: false, includeToolCalls: false }) + ) + }) + it('returns 403 when the updated auth type changes to a blocked mode', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' }, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 08acae6daa0..a79b6ed32d4 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -62,6 +62,7 @@ export const GET = withRouteHandler( const result = { ...safeData, + includeToolCalls: safeData.includeToolCalls ?? safeData.includeThinking ?? false, chatUrl, hasPassword: !!password, } @@ -117,6 +118,8 @@ export const PATCH = withRouteHandler( password, allowedEmails, outputConfigs, + includeThinking, + includeToolCalls, } = validatedData if (workflowId && workflowId !== existingChat[0].workflowId) { @@ -250,6 +253,21 @@ export const PATCH = withRouteHandler( updateData.outputConfigs = outputConfigs } + if (includeThinking !== undefined) { + updateData.includeThinking = includeThinking + } + + /** + * Grandfathering must resolve against the thinking value this request is + * setting, not the stored one. Before `includeToolCalls` existed, + * `includeThinking` gated tool frames too, so a partial update turning + * thinking off has to turn them off as well rather than materializing the + * stale `true` and silently leaving tool frames enabled. + */ + const effectiveIncludeThinking = includeThinking ?? existingChatRecord.includeThinking + updateData.includeToolCalls = + includeToolCalls ?? existingChatRecord.includeToolCalls ?? effectiveIncludeThinking ?? false + const emailCount = Array.isArray(updateData.allowedEmails) ? updateData.allowedEmails.length : undefined @@ -263,6 +281,8 @@ export const PATCH = withRouteHandler( hasPassword: updateData.password !== undefined, emailCount, outputConfigsCount, + includeThinking: updateData.includeThinking, + includeToolCalls: updateData.includeToolCalls, }) await db.update(chat).set(updateData).where(eq(chat.id, chatId)) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index ab4ebded2cf..e40afe0caee 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -454,6 +454,8 @@ describe('Chat API Route', () => { expect.objectContaining({ workflowId: 'workflow-123', userId: 'user-id', + includeThinking: false, + includeToolCalls: false, }) ) }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index 03f4e5377af..74091053c61 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -32,7 +32,12 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { .from(chat) .where(and(eq(chat.userId, session.user.id), isNull(chat.archivedAt))) - return createSuccessResponse({ deployments }) + return createSuccessResponse({ + deployments: deployments.map((deployment) => ({ + ...deployment, + includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking, + })), + }) } catch (error) { logger.error('Error fetching chat deployments:', error) return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployments'), 500) @@ -68,6 +73,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { password, allowedEmails = [], outputConfigs = [], + includeThinking = false, + includeToolCalls = false, } = parsed.data.body if (authType === 'password' && !password) { @@ -127,6 +134,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { password, allowedEmails, outputConfigs, + includeThinking, + includeToolCalls, workspaceId: workflowRecord.workspaceId, }) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 4b5c2194584..b240a68a099 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -14,7 +14,7 @@ import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockExecuteInE2B, + mockExecuteInSandbox, mockExecuteInIsolatedVM, mockFetchWorkspaceFileBuffer, mockGetWorkspaceFile, @@ -24,7 +24,7 @@ const { mockValidateWorkspaceFileWriteTarget, mockWriteWorkspaceFileByPath, } = vi.hoisted(() => ({ - mockExecuteInE2B: vi.fn(), + mockExecuteInSandbox: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), mockFetchWorkspaceFileBuffer: vi.fn(), mockGetWorkspaceFile: vi.fn(), @@ -39,9 +39,9 @@ vi.mock('@/lib/execution/isolated-vm', () => ({ executeInIsolatedVM: mockExecuteInIsolatedVM, })) -vi.mock('@/lib/execution/e2b', () => ({ - executeInE2B: mockExecuteInE2B, - executeShellInE2B: vi.fn(), +vi.mock('@/lib/execution/remote-sandbox', () => ({ + executeInSandbox: mockExecuteInSandbox, + executeShellInSandbox: vi.fn(), SIM_RESULT_PREFIX: '__SIM_RESULT__=', })) @@ -113,7 +113,7 @@ afterAll(resetEnvFlagsMock) describe('Function Execute API Route', () => { beforeEach(() => { vi.clearAllMocks() - envFlagsMock.isE2bEnabled = false + envFlagsMock.isRemoteSandboxEnabled = false hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, @@ -125,7 +125,7 @@ describe('Function Execute API Route', () => { mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) clearLargeValueCacheForTests() - mockExecuteInE2B.mockResolvedValue({ + mockExecuteInSandbox.mockResolvedValue({ result: 'e2b success', stdout: 'e2b output', sandboxId: 'test-sandbox-id', @@ -351,8 +351,8 @@ describe('Function Execute API Route', () => { }) it('exports multiple declared sandbox output files', async () => { - envFlagsMock.isE2bEnabled = true - mockExecuteInE2B.mockResolvedValueOnce({ + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', stdout: 'ok', sandboxId: 'sandbox-123', @@ -389,7 +389,7 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) - expect(mockExecuteInE2B).toHaveBeenCalledWith( + expect(mockExecuteInSandbox).toHaveBeenCalledWith( expect.objectContaining({ outputSandboxPaths: ['/home/user/chart.png', '/home/user/summary.json'], }) @@ -419,8 +419,8 @@ describe('Function Execute API Route', () => { }) it('prevalidates all sandbox output destinations before writing any files', async () => { - envFlagsMock.isE2bEnabled = true - mockExecuteInE2B.mockResolvedValueOnce({ + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', stdout: 'ok', sandboxId: 'sandbox-123', @@ -463,8 +463,8 @@ describe('Function Execute API Route', () => { }) it('rejects duplicate sandbox output destinations before writing files', async () => { - envFlagsMock.isE2bEnabled = true - mockExecuteInE2B.mockResolvedValueOnce({ + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', stdout: 'ok', sandboxId: 'sandbox-123', @@ -508,8 +508,8 @@ describe('Function Execute API Route', () => { }) it('returns a targeted error when a declared sandbox output is missing', async () => { - envFlagsMock.isE2bEnabled = true - mockExecuteInE2B.mockResolvedValueOnce({ + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', stdout: 'ok', sandboxId: 'sandbox-123', @@ -541,7 +541,7 @@ describe('Function Execute API Route', () => { }) it('rejects sandboxPath outputs when the call would run in isolated-vm (E2B enabled, JS without imports)', async () => { - envFlagsMock.isE2bEnabled = true + envFlagsMock.isRemoteSandboxEnabled = true const req = createMockRequest('POST', { code: 'return "content"', @@ -565,7 +565,7 @@ describe('Function Execute API Route', () => { expect(data.success).toBe(false) expect(data.error).toContain('no sandbox filesystem') expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() - expect(mockExecuteInE2B).not.toHaveBeenCalled() + expect(mockExecuteInSandbox).not.toHaveBeenCalled() expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() }) @@ -582,16 +582,16 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(422) expect(data.success).toBe(false) - // E2B is disabled in this test, so the remediation must name that cause - // instead of suggesting python (which would also fail without E2B). - expect(data.error).toContain('E2B is not enabled') + // No remote sandbox is enabled in this test, so the remediation must name + // that cause instead of suggesting python (which would also fail without one). + expect(data.error).toContain('No remote code sandbox is enabled') expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() }) it('flags an overwrite export whose bytes are identical to the current file content as unchanged', async () => { - envFlagsMock.isE2bEnabled = true + envFlagsMock.isRemoteSandboxEnabled = true const staleContent = '# doc\nunchanged mounted content\n' - mockExecuteInE2B.mockResolvedValueOnce({ + mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', stdout: 'ok', sandboxId: 'sandbox-123', @@ -636,9 +636,9 @@ describe('Function Execute API Route', () => { }) it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { - envFlagsMock.isE2bEnabled = true + envFlagsMock.isRemoteSandboxEnabled = true const newContent = '# doc\nnew content\n' - mockExecuteInE2B.mockResolvedValueOnce({ + mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', stdout: 'ok', sandboxId: 'sandbox-123', @@ -682,7 +682,7 @@ describe('Function Execute API Route', () => { expect(data.output.result.message).toContain('sha256:') // The python wrapper prints the marker with a leading \n so it always // starts a fresh line even after non-newline-terminated user output. - const e2bCode = mockExecuteInE2B.mock.calls[0][0].code as string + const e2bCode = mockExecuteInSandbox.mock.calls[0][0].code as string expect(e2bCode).toContain("print('\\n__SIM_RESULT__=' + json.dumps(__sim_result__))") }) @@ -727,7 +727,7 @@ describe('Function Execute API Route', () => { }) it('rejects large refs in runtimes without ref-native helpers', async () => { - envFlagsMock.isE2bEnabled = true + envFlagsMock.isRemoteSandboxEnabled = true const req = createMockRequest('POST', { code: 'echo "$__blockRef_0"', language: 'shell', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 20568fd2010..85a0ea43bde 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -16,10 +16,9 @@ import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath, } from '@/lib/copilot/vfs/resource-writer' -import { isE2bEnabled } from '@/lib/core/config/env-flags' +import { isRemoteSandboxEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { executeInE2B, executeShellInE2B, SIM_RESULT_PREFIX } from '@/lib/execution/e2b' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys' @@ -36,6 +35,11 @@ import { } from '@/lib/execution/payloads/materialization.server' import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { materializeLargeValueRef } from '@/lib/execution/payloads/store' +import { + executeInSandbox, + executeShellInSandbox, + SIM_RESULT_PREFIX, +} from '@/lib/execution/remote-sandbox' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' import { fetchWorkspaceFileBuffer, @@ -1509,9 +1513,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } if (lang === CodeLanguage.Shell) { - if (!isE2bEnabled) { + if (!isRemoteSandboxEnabled) { throw new Error( - 'Shell execution requires E2B to be enabled. Please contact your administrator to enable E2B.' + 'Shell execution requires a remote code sandbox to be enabled. Please contact your administrator to enable it.' ) } @@ -1524,7 +1528,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } logger.info(`[${requestId}] E2B shell execution`, { - enabled: isE2bEnabled, + enabled: isRemoteSandboxEnabled, hasApiKey: Boolean(process.env.E2B_API_KEY), envVarCount: Object.keys(shellEnvs).length, }) @@ -1537,7 +1541,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { error: shellError, exportedFileContent, exportedFiles, - } = await executeShellInE2B({ + } = await executeShellInSandbox({ code: resolvedCode, envs: shellEnvs, timeoutMs: timeout, @@ -1589,41 +1593,44 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (lang === CodeLanguage.Python && !isE2bEnabled) { + if (lang === CodeLanguage.Python && !isRemoteSandboxEnabled) { throw new Error( - 'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.' + 'Python execution requires a remote code sandbox to be enabled. Please contact your administrator to enable it, or use JavaScript instead.' ) } - if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) { + if (lang === CodeLanguage.JavaScript && hasImports && !isRemoteSandboxEnabled) { throw new Error( - 'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.' + 'JavaScript code with import statements requires a remote code sandbox to be enabled. Please remove the import statements, or contact your administrator to enable it.' ) } - const useE2B = - isE2bEnabled && + const useRemoteSandbox = + isRemoteSandboxEnabled && !isCustomTool && (lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports)) - if (useE2B && containsLargeValueRef(contextVariables)) { + if (useRemoteSandbox && containsLargeValueRef(contextVariables)) { throw new Error( - 'Large execution values require the JavaScript isolated-vm runtime. Remove imports, select a nested field, or read the value in a JavaScript function without E2B.' + 'Large execution values require the JavaScript isolated-vm runtime. Remove imports, select a nested field, or read the value in a JavaScript function without a remote sandbox.' ) } - // Sandbox file mounts and sandboxPath exports only exist in the E2B - // runtime; isolated-vm has no filesystem. Silently dropping a declared + // Sandbox file mounts and sandboxPath exports only exist in the remote + // sandbox runtime; isolated-vm has no filesystem. Silently dropping a declared // sandbox input/output here produced "export succeeded" responses with // zero bytes written, so refuse the call instead. The remediation depends // on WHY this call runs in isolated-vm — "switch to python" is a dead end - // when E2B is disabled or the call is a custom tool. - if (!useE2B && (outputSandboxPaths.length > 0 || outputSandboxPath || _sandboxFiles?.length)) { - const remediation = !isE2bEnabled - ? "E2B is not enabled on this deployment, so there is no sandbox filesystem for any language. Pass input data via params and return output as the code's return value with outputs.files[].path (no sandboxPath)." + // when no remote sandbox is enabled or the call is a custom tool. + if ( + !useRemoteSandbox && + (outputSandboxPaths.length > 0 || outputSandboxPath || _sandboxFiles?.length) + ) { + const remediation = !isRemoteSandboxEnabled + ? "No remote code sandbox is enabled on this deployment, so there is no sandbox filesystem for any language. Pass input data via params and return output as the code's return value with outputs.files[].path (no sandboxPath)." : isCustomTool ? "custom tools always run in the isolated JavaScript VM, which has no sandbox filesystem. Pass input data via params and return output as the code's return value." - : 'plain JavaScript runs in the isolated VM, which has no sandbox filesystem. Use language "python" so the code runs in the E2B sandbox, or drop sandboxPath and return the file content as the code\'s return value with outputs.files[].path.' + : 'plain JavaScript runs in the isolated VM, which has no sandbox filesystem. Use language "python" so the code runs in the remote sandbox, or drop sandboxPath and return the file content as the code\'s return value with outputs.files[].path.' return functionJsonResponse( { success: false, @@ -1635,9 +1642,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (useE2B) { + if (useRemoteSandbox) { logger.info(`[${requestId}] E2B status`, { - enabled: isE2bEnabled, + enabled: isRemoteSandboxEnabled, hasApiKey: Boolean(process.env.E2B_API_KEY), language: lang, }) @@ -1693,7 +1700,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { error: e2bError, exportedFileContent, exportedFiles, - } = await executeInE2B({ + } = await executeInSandbox({ code: codeForE2B, language: CodeLanguage.JavaScript, timeoutMs: timeout, @@ -1781,7 +1788,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { error: e2bError, exportedFileContent, exportedFiles, - } = await executeInE2B({ + } = await executeInSandbox({ code: codeForE2B, language: CodeLanguage.Python, timeoutMs: timeout, diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 0c0d4128d09..4a9c5e1f0e5 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -308,10 +308,23 @@ describe('Knowledge Search Utils', () => { it('should throw error when no API configuration provided', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) + // The env object lazily reads process.env, so a developer's local .env + // keys survive the deletion above — stub the direct key empty and fail + // the hosted rotation fallback for hermeticity on any machine. + vi.stubEnv('OPENAI_API_KEY', '') + const apiKeysModule = await import('@/lib/core/config/api-keys') + const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => { + throw new Error('No rotation keys configured') + }) - await expect(generateSearchEmbedding('test query')).rejects.toThrow( - 'OPENAI_API_KEY is not configured' - ) + try { + await expect(generateSearchEmbedding('test query')).rejects.toThrow( + 'OPENAI_API_KEY is not configured' + ) + } finally { + rotationSpy.mockRestore() + vi.unstubAllEnvs() + } }) it('should handle Azure OpenAI API errors properly', async () => { diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index f7a0297ef52..3ec06bc2e55 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -345,10 +345,23 @@ describe('Knowledge Utils', () => { it('should throw error when no API configuration provided', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) + // The env object lazily reads process.env, so a developer's local .env + // keys survive the deletion above — stub the direct key empty and fail + // the hosted rotation fallback for hermeticity on any machine. + vi.stubEnv('OPENAI_API_KEY', '') + const apiKeysModule = await import('@/lib/core/config/api-keys') + const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => { + throw new Error('No rotation keys configured') + }) - await expect(generateEmbeddings(['test text'])).rejects.toThrow( - 'OPENAI_API_KEY is not configured' - ) + try { + await expect(generateEmbeddings(['test text'])).rejects.toThrow( + 'OPENAI_API_KEY is not configured' + ) + } finally { + rotationSpy.mockRestore() + vi.unstubAllEnvs() + } }) }) }) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 62eb7bc22ed..36641a775f2 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -18,11 +18,10 @@ import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/co import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' -import { isE2BDocEnabled } from '@/lib/core/config/env-flags' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assertActiveWorkspaceAccess, - getUserEntityPermissions, isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' import type { ChatContext } from '@/stores/panel' @@ -130,7 +129,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const userId = auth.userId ?? bodyUserId - await assertActiveWorkspaceAccess(workspaceId, userId) + const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const billingAttribution = requireBillingAttributionHeader(req.headers, { actorUserId: userId, workspaceId, @@ -152,38 +151,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { context.kind === 'mcp' && context.serverId ? [context.serverId] : [] ) const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp') - const [ - workspaceContext, - integrationTools, - mothershipTools, - userPermission, - entitlements, - agentContexts, - ] = await Promise.all([ - generateWorkspaceContext(workspaceId, userId), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - Promise.all([ - buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), - buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), - ]).then((groups) => { - const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) - return [...byName.values()] - }), - getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), - computeWorkspaceEntitlements(workspaceId, userId), - processContextsServer( - nonMcpAgentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), - ]) + const userPermission = workspaceAccess.permission + const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] = + await Promise.all([ + generateWorkspaceContext(workspaceId, userId, { workspaceAccess }), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + Promise.all([ + buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), + buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), + ]).then((groups) => { + const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) + return [...byName.values()] + }), + computeWorkspaceEntitlements(workspaceId, userId), + processContextsServer( + nonMcpAgentMentions, + userId, + lastUserMessage, + workspaceId, + effectiveChatId + ).catch((error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + }), + ]) const requestPayload: Record = { messages, responseFormat, @@ -199,7 +192,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { messageId, isHosted: true, workspaceContext, - ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), + ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), ...(userMetadata ? { userMetadata } : {}), ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), ...(agentContexts.length > 0 || mothershipTools.length > 0 diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts new file mode 100644 index 00000000000..6dd6c29ada1 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { member } from '@sim/db/schema' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsEnterprise, mockRecordAudit } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { ORGANIZATION_DOMAIN_REMOVED: 'organization.domain.removed' }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +import { DELETE } from '@/app/api/organizations/[id]/domains/[domainId]/route' + +const routeContext = { params: Promise.resolve({ id: 'org-1', domainId: 'd1' }) } + +describe('remove org domain route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + }) + mockIsEnterprise.mockResolvedValue(true) + }) + + it('401s when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(401) + }) + + it('403s for non-admins', async () => { + queueTableRows(member, [{ role: 'member' }]) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(403) + }) + + it('403s for non-Enterprise orgs', async () => { + queueTableRows(member, [{ role: 'owner' }]) + mockIsEnterprise.mockResolvedValue(false) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(403) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('404s when the domain does not exist', async () => { + queueTableRows(member, [{ role: 'owner' }]) + dbChainMockFns.returning.mockResolvedValueOnce([]) // delete matched nothing + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(404) + }) + + it('removes the domain and records an audit event', async () => { + queueTableRows(member, [{ role: 'owner' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }]) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(200) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.domain.removed' }) + ) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts new file mode 100644 index 00000000000..9dd220c2274 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts @@ -0,0 +1,87 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, ssoDomain } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgDomainDeleteAPI') + +/** + * DELETE /api/organizations/[id]/domains/[domainId] + * Removes a claimed/verified domain. Requires owner/admin role. Removing a + * verified domain drops the ownership proof, so SSO can no longer be configured + * for it until it is re-verified. It does not retroactively un-register an + * already-configured SSO provider — that flows through the SSO provider itself. + */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(removeOrganizationDomainContract, request, context) + if (!parsed.success) return parsed.response + const { id: organizationId, domainId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can remove domains' }, + { status: 403 } + ) + } + // Enterprise-gate removal like add/verify so all domain mutations require the + // same entitlement (the UI already hides removal from non-Enterprise orgs). + if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) { + return NextResponse.json( + { error: 'Domain verification is available on Enterprise plans only' }, + { status: 403 } + ) + } + + const [removed] = await db + .delete(ssoDomain) + .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId))) + .returning({ domain: ssoDomain.domain }) + + if (!removed) { + return NextResponse.json({ error: 'Domain not found' }, { status: 404 }) + } + + logger.info('Domain removed', { organizationId, domain: removed.domain }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_DOMAIN_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Removed domain ${removed.domain}`, + metadata: { domain: removed.domain }, + request, + }) + + return NextResponse.json({ success: true }) + } +) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts new file mode 100644 index 00000000000..11b14ddd877 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { member, ssoDomain } from '@sim/db/schema' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsEnterprise, mockRecordAudit, mockCheckDomainTxtRecord } = vi.hoisted( + () => ({ + mockGetSession: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRecordAudit: vi.fn(), + mockCheckDomainTxtRecord: vi.fn(), + }) +) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { ORGANIZATION_DOMAIN_VERIFIED: 'organization.domain.verified' }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +vi.mock('@/lib/auth/sso/domain-verification', () => ({ + checkDomainTxtRecord: mockCheckDomainTxtRecord, + toDomainResponse: (row: { id: string; status: string }) => ({ id: row.id, status: row.status }), +})) + +import { POST } from '@/app/api/organizations/[id]/domains/[domainId]/verify/route' + +const ORG_ID = 'org-1' +const DOMAIN_ID = 'd1' +const routeContext = { params: Promise.resolve({ id: ORG_ID, domainId: DOMAIN_ID }) } +const PENDING_ROW = { + id: DOMAIN_ID, + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, +} + +/** Queues the membership + pending-row lookups shared by the happy path. */ +function queueAdminWithPendingRow() { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(ssoDomain, [PENDING_ROW]) // row lookup +} + +describe('verify org domain route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + }) + mockIsEnterprise.mockResolvedValue(true) + mockCheckDomainTxtRecord.mockResolvedValue(true) + }) + + it('422s when the TXT record is not found', async () => { + queueAdminWithPendingRow() + mockCheckDomainTxtRecord.mockResolvedValue(false) + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(422) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('verifies the domain and records an audit event', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + dbChainMockFns.returning.mockResolvedValueOnce([{ ...PENDING_ROW, status: 'verified' }]) + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ status: 'verified' }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.domain.verified' }) + ) + }) + + it('is idempotent when a concurrent same-org request already verified the row', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + dbChainMockFns.returning.mockResolvedValueOnce([]) // our conditional update lost the race + queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }]) // re-read: now verified + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ status: 'verified' }) + expect(mockRecordAudit).not.toHaveBeenCalled() // the winning request recorded it + }) + + it('409s when the row changed (deleted/re-tokenized) during the DNS lookup', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + dbChainMockFns.returning.mockResolvedValueOnce([]) // conditional update matched no row + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(409) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('409s (not 500) when a concurrent cross-org verification wins the unique index', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none at read time + dbChainMockFns.returning.mockRejectedValueOnce( + Object.assign(new Error('duplicate key'), { code: '23505' }) + ) + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(409) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts new file mode 100644 index 00000000000..9f1c710a2cb --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts @@ -0,0 +1,162 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, ssoDomain } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { checkDomainTxtRecord, toDomainResponse } from '@/lib/auth/sso/domain-verification' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgDomainVerifyAPI') + +/** + * POST /api/organizations/[id]/domains/[domainId]/verify + * Checks the domain's DNS TXT challenge record; on success flips it to + * `verified`. Requires enterprise plan and owner/admin role. A domain already + * verified by another org is refused (the partial unique index also guards it). + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(verifyOrganizationDomainContract, request, context) + if (!parsed.success) return parsed.response + const { id: organizationId, domainId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can verify domains' }, + { status: 403 } + ) + } + if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) { + return NextResponse.json( + { error: 'Domain verification is available on Enterprise plans only' }, + { status: 403 } + ) + } + + const [row] = await db + .select() + .from(ssoDomain) + .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId))) + .limit(1) + if (!row) { + return NextResponse.json({ error: 'Domain not found' }, { status: 404 }) + } + if (row.status === 'verified') { + return NextResponse.json({ success: true, data: { domain: toDomainResponse(row) } }) + } + + const recordPresent = await checkDomainTxtRecord(row.domain, row.verificationToken) + if (!recordPresent) { + return NextResponse.json( + { + error: + 'The verification TXT record was not found yet. DNS changes can take up to 48 hours to propagate — add the record shown and try again.', + }, + { status: 422 } + ) + } + + // Friendly early return for the common case where another org already owns + // the domain (the partial unique index is the actual enforcer, below). + const [verifiedElsewhere] = await db + .select({ organizationId: ssoDomain.organizationId }) + .from(ssoDomain) + .where(and(eq(ssoDomain.domain, row.domain), eq(ssoDomain.status, 'verified'))) + .limit(1) + if (verifiedElsewhere && verifiedElsewhere.organizationId !== organizationId) { + return NextResponse.json( + { error: 'This domain is already verified by another organization' }, + { status: 409 } + ) + } + + // Flip to verified only if the row is still the exact pending challenge we + // checked. If it was deleted, re-tokenized, or already verified while the + // DNS lookup was in flight, this matches zero rows — we then ask for a retry + // instead of mapping an undefined row or trusting a superseded challenge. A + // concurrent cross-org verification trips the partial unique index; surface + // that as a 409 rather than an unhandled 500. + let updated: (typeof row)[] + try { + updated = await db + .update(ssoDomain) + .set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(ssoDomain.id, domainId), + eq(ssoDomain.verificationToken, row.verificationToken), + eq(ssoDomain.status, 'pending') + ) + ) + .returning() + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { error: 'This domain is already verified by another organization' }, + { status: 409 } + ) + } + throw error + } + + if (updated.length === 0) { + // The conditional update matched nothing. If a concurrent request for this + // same org already flipped the row to verified, treat this as an idempotent + // success; otherwise the challenge is genuinely stale (row deleted or + // re-tokenized mid-lookup) and we ask the caller to retry. + const [current] = await db + .select() + .from(ssoDomain) + .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId))) + .limit(1) + if (current?.status === 'verified') { + return NextResponse.json({ success: true, data: { domain: toDomainResponse(current) } }) + } + return NextResponse.json( + { error: 'The domain changed during verification. Refresh and try again.' }, + { status: 409 } + ) + } + + logger.info('Domain verified', { organizationId, domain: row.domain }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_DOMAIN_VERIFIED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Verified domain ${row.domain}`, + metadata: { domain: row.domain }, + request, + }) + + return NextResponse.json({ success: true, data: { domain: toDomainResponse(updated[0]) } }) + } +) diff --git a/apps/sim/app/api/organizations/[id]/domains/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/route.test.ts new file mode 100644 index 00000000000..5da2ec2c53a --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/route.test.ts @@ -0,0 +1,206 @@ +/** + * @vitest-environment node + */ +import { member, ssoDomain } from '@sim/db/schema' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsEnterprise, mockRecordAudit } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { ORGANIZATION_DOMAIN_ADDED: 'organization.domain.added' }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +import { GET, POST } from '@/app/api/organizations/[id]/domains/route' + +const ORG_ID = 'org-1' +const routeContext = { params: Promise.resolve({ id: ORG_ID }) } + +describe('org domains route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + session: { token: 'tok-1' }, + }) + mockIsEnterprise.mockResolvedValue(true) + }) + + describe('GET', () => { + it('401s when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(401) + }) + + it('403s for non-members', async () => { + queueTableRows(member, []) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(403) + }) + + const PENDING = { + id: 'd1', + domain: 'acme.com', + status: 'pending', + verificationToken: 'secret', + verifiedAt: null, + } + + it('returns the pending TXT token to admins', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(ssoDomain, [PENDING]) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domains[0]).toMatchObject({ + domain: 'acme.com', + status: 'pending', + txtRecordValue: 'sim-domain-verification=secret', + }) + }) + + it('redacts the pending TXT token for non-admin members', async () => { + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(ssoDomain, [PENDING]) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domains[0]).toMatchObject({ + domain: 'acme.com', + status: 'pending', + txtRecordValue: null, // secret hidden from members + }) + }) + + it('returns an empty list (no domains/tokens) for non-Enterprise orgs', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mockIsEnterprise.mockResolvedValue(false) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual({ isEnterprise: false, domains: [] }) + }) + }) + + describe('POST', () => { + function req(body: unknown) { + return createMockRequest('POST', body) + } + + it('403s for non-admins', async () => { + queueTableRows(member, [{ role: 'member' }]) + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(403) + }) + + it('400s on an invalid domain', async () => { + queueTableRows(member, [{ role: 'owner' }]) + const res = await POST(req({ domain: 'not a domain' }), routeContext) + expect(res.status).toBe(400) + }) + + it('409s when the domain is verified by another org', async () => { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(ssoDomain, [{ organizationId: 'other-org' }]) + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(409) + }) + + it('claims a new domain as pending and records an audit event', async () => { + queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + queueTableRows(ssoDomain, []) // org-domains read → none existing, under the cap + // insert().returning() + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'd-new', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, + }, + ]) + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ + status: 'pending', + txtRecordValue: 'sim-domain-verification=tok', + }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.domain.added' }) + ) + }) + + it('re-adds an existing pending domain idempotently without rotating its token', async () => { + queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + queueTableRows(ssoDomain, [ + { + id: 'd-existing', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok-existing', + verifiedAt: null, + }, + ]) // org-domains read → already claimed, still pending + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ + id: 'd-existing', + status: 'pending', + txtRecordValue: 'sim-domain-verification=tok-existing', // unchanged, not rotated + }) + // No write occurred — the existing row is returned as-is. + expect(dbChainMockFns.returning).not.toHaveBeenCalled() + }) + + it('stays idempotent when a concurrent claim wins the unique index race', async () => { + queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + queueTableRows(ssoDomain, []) // org-domains read → none existing, under the cap + // insert().returning() loses the race and hits sso_domain_org_domain_unique + dbChainMockFns.returning.mockRejectedValueOnce( + Object.assign(new Error('duplicate key'), { code: '23505' }) + ) + queueTableRows(ssoDomain, [ + { + id: 'd-winner', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok-winner', + verifiedAt: null, + }, + ]) // re-read returns the row that landed + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ id: 'd-winner', status: 'pending' }) + }) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/domains/route.ts b/apps/sim/app/api/organizations/[id]/domains/route.ts new file mode 100644 index 00000000000..88ae74b8389 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/route.ts @@ -0,0 +1,217 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, ssoDomain } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { and, asc, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + addOrganizationDomainContract, + MAX_ORGANIZATION_DOMAINS, +} from '@/lib/api/contracts/organization' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { generateVerificationToken, toDomainResponse } from '@/lib/auth/sso/domain-verification' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgDomainsAPI') + +/** + * GET /api/organizations/[id]/domains + * Lists the organization's claimed domains and their verification state. + * Accessible by any member. + */ +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + // Domain management is Enterprise-only, so a non-Enterprise org has no + // domains to return — surface only the entitlement flag (which drives the + // upgrade prompt) and never the list/tokens. + if (!isEnterprise) { + return NextResponse.json({ success: true, data: { isEnterprise: false, domains: [] } }) + } + + const rows = await db + .select() + .from(ssoDomain) + .where(eq(ssoDomain.organizationId, organizationId)) + .orderBy(asc(ssoDomain.createdAt)) + + // The pending TXT token is a management secret; only owner/admins (who can + // add/verify/remove) may read it. Members see the list and status without it. + const includeToken = isOrgAdminRole(memberEntry.role) + return NextResponse.json({ + success: true, + data: { + isEnterprise: true, + domains: rows.map((row) => toDomainResponse(row, { includeToken })), + }, + }) + } +) + +/** + * POST /api/organizations/[id]/domains + * Claims a domain and mints a DNS TXT verification token. Requires enterprise + * plan and owner/admin role. The domain starts `pending`; the org proves + * ownership by publishing the token and calling the verify endpoint. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(addOrganizationDomainContract, request, context, { + validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'), + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can manage domains' }, + { status: 403 } + ) + } + if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) { + return NextResponse.json( + { error: 'Domain verification is available on Enterprise plans only' }, + { status: 403 } + ) + } + + const domain = normalizeSSODomain(parsed.data.body.domain) + if (!domain) { + return NextResponse.json( + { error: 'Enter a valid domain, for example acme.com' }, + { status: 400 } + ) + } + + // A domain already verified by ANOTHER org cannot be claimed here; the + // partial unique index enforces this at write time, but check first for a + // clear error. Pending claims by others are allowed to coexist. + const [verifiedElsewhere] = await db + .select({ organizationId: ssoDomain.organizationId }) + .from(ssoDomain) + .where(and(eq(ssoDomain.domain, domain), eq(ssoDomain.status, 'verified'))) + .limit(1) + if (verifiedElsewhere && verifiedElsewhere.organizationId !== organizationId) { + return NextResponse.json( + { error: 'This domain is already verified by another organization' }, + { status: 409 } + ) + } + + // One bounded read (an org holds at most MAX_ORGANIZATION_DOMAINS rows) + // serves both the idempotent re-add check and the per-org cap. + const orgDomains = await db + .select() + .from(ssoDomain) + .where(eq(ssoDomain.organizationId, organizationId)) + + const existing = orgDomains.find((d) => d.domain === domain) + if (existing) { + // Idempotent: re-adding a domain the org already has returns the existing + // row unchanged. We deliberately do NOT rotate the token — the pending + // token is always shown in the UI (so it is never "lost"), rotating would + // invalidate a TXT record the admin may have already published, and under + // concurrent re-adds a rotation could hand back a token that a racing + // write has already superseded. + return NextResponse.json({ success: true, data: { domain: toDomainResponse(existing) } }) + } + + if (orgDomains.length >= MAX_ORGANIZATION_DOMAINS) { + return NextResponse.json( + { error: `An organization can claim at most ${MAX_ORGANIZATION_DOMAINS} domains` }, + { status: 400 } + ) + } + + let created: (typeof orgDomains)[number] + try { + ;[created] = await db + .insert(ssoDomain) + .values({ + id: generateId(), + organizationId, + domain, + status: 'pending', + verificationToken: generateVerificationToken(), + createdBy: session.user.id, + }) + .returning() + } catch (error) { + // A concurrent request for the same (org, domain) won the race and the + // sso_domain_org_domain_unique index rejected this insert. Stay + // idempotent: return the row that landed instead of surfacing a 500. + if (getPostgresErrorCode(error) === '23505') { + const [winner] = await db + .select() + .from(ssoDomain) + .where(and(eq(ssoDomain.organizationId, organizationId), eq(ssoDomain.domain, domain))) + .limit(1) + if (winner) { + return NextResponse.json({ success: true, data: { domain: toDomainResponse(winner) } }) + } + } + throw error + } + + logger.info('Domain claimed for verification', { organizationId, domain }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_DOMAIN_ADDED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Claimed domain ${domain} for verification`, + metadata: { domain }, + request, + }) + + return NextResponse.json({ success: true, data: { domain: toDomainResponse(created) } }) + } +) diff --git a/apps/sim/app/api/providers/route.test.ts b/apps/sim/app/api/providers/route.test.ts index 97161bc7972..efb762da07e 100644 --- a/apps/sim/app/api/providers/route.test.ts +++ b/apps/sim/app/api/providers/route.test.ts @@ -111,6 +111,54 @@ describe('POST /api/providers', () => { ) }) + it('omits provisional stream output from the execution header', async () => { + mockExecuteProviderRequest.mockResolvedValue({ + streamFormat: 'agent-events-v1', + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'text_delta', text: 'hello', turn: 'final' }) + controller.close() + }, + }), + execution: { + success: true, + output: { + content: '', + model: 'gpt-4o', + tokens: { input: 0, output: 0, total: 0 }, + cost: { input: 0, output: 0, total: 0 }, + providerTiming: { + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.000Z', + duration: 0, + }, + }, + logs: [], + metadata: { + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.000Z', + duration: 0, + }, + isStreaming: true, + }, + }) + + const res = await POST( + createMockRequest('POST', { + provider: 'openai', + model: 'gpt-4o', + workspaceId: 'ws-1', + stream: true, + }) + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('hello') + const executionHeader = JSON.parse(res.headers.get('X-Execution-Data') ?? '{}') + expect(executionHeader.output).toEqual({ model: 'gpt-4o' }) + expect(executionHeader.metadata).toEqual({ startTime: '2026-07-01T00:00:00.000Z' }) + }) + it('rejects an attribution header when the body has no workspaceId to validate against', async () => { const res = await POST( createMockRequest( diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index dbb87f071c9..7d16cf8ee16 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -29,6 +29,7 @@ import { } from '@/ee/access-control/utils/permission-check' import type { StreamingExecution } from '@/executor/types' import { executeProviderRequest } from '@/providers' +import { projectStreamingExecutionToByteStream } from '@/providers/stream-pump' const logger = createLogger('ProvidersAPI') @@ -273,43 +274,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const streamingExec = response as StreamingExecution logger.info(`[${requestId}] Received StreamingExecution from provider`) - // Extract the stream and execution data - const stream = streamingExec.stream const executionData = streamingExec.execution + const byteStream = projectStreamingExecutionToByteStream(streamingExec) - // Attach the execution data as a custom header - // We need to safely serialize the execution data to avoid circular references let executionDataHeader try { - // Create a safe version of execution data with the most important fields + const outputContent = executionData.output?.content + const outputTokens = executionData.output?.tokens + const hasSettledOutput = + Boolean(outputContent) || + Boolean(outputTokens?.total) || + Boolean(executionData.output?.toolCalls) const safeExecutionData = { success: executionData.success, output: { - // Sanitize content to remove non-ASCII characters that would cause ByteString errors - content: executionData.output?.content - ? String(executionData.output.content).replace(/[\u0080-\uFFFF]/g, '') - : '', model: executionData.output?.model, - tokens: executionData.output?.tokens || { - input: 0, - output: 0, - total: 0, - }, - // Sanitize any potential Unicode characters in tool calls - toolCalls: executionData.output?.toolCalls - ? sanitizeToolCalls(executionData.output.toolCalls) - : undefined, - providerTiming: executionData.output?.providerTiming, - cost: executionData.output?.cost, + ...(hasSettledOutput + ? { + content: String(outputContent ?? '').replace(/[\u0080-\uFFFF]/g, ''), + tokens: outputTokens, + toolCalls: executionData.output?.toolCalls + ? sanitizeToolCalls(executionData.output.toolCalls) + : undefined, + providerTiming: executionData.output?.providerTiming, + cost: executionData.output?.cost, + } + : {}), }, error: executionData.error, - logs: [], // Strip logs from header to avoid encoding issues + logs: [], metadata: { startTime: executionData.metadata?.startTime, - endTime: executionData.metadata?.endTime, - duration: executionData.metadata?.duration, + ...(hasSettledOutput + ? { + endTime: executionData.metadata?.endTime, + duration: executionData.metadata?.duration, + } + : {}), }, - isStreaming: true, // Always mark streaming execution data as streaming + isStreaming: true, blockId: executionData.logs?.[0]?.blockId, blockName: executionData.logs?.[0]?.blockName, blockType: executionData.logs?.[0]?.blockType, @@ -323,8 +326,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - // Return the stream with execution data in a header - return new Response(stream, { + return new Response(byteStream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 6ea631652f6..992dcd3dc47 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -20,7 +20,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import type { ResumeExecutionPayload } from '@/background/resume-execution' import { ExecutionSnapshot } from '@/executor/execution/snapshot' @@ -250,6 +253,8 @@ export const POST = withRouteHandler( const executionMode = isApiCaller ? (persistedSnapshot.metadata.executionMode ?? 'sync') : undefined + const includeThinking = persistedSnapshot.metadata.includeThinking === true + const includeToolCalls = persistedSnapshot.metadata.includeToolCalls ?? includeThinking if (isApiCaller && executionMode === 'stream') { const stream = await createStreamingResponse({ @@ -257,12 +262,16 @@ export const POST = withRouteHandler( streamConfig: { selectedOutputs: persistedSnapshot.selectedOutputs, timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking, + includeToolCalls, }, executionId: enqueueResult.resumeExecutionId, workspaceId: workflow.workspaceId || undefined, workflowId, userId: enqueueResult.userId, allowLargeValueWorkflowScope: true, + requestSignal: request.signal, + requestHeaders: request.headers, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => PauseResumeManager.startResumeExecution({ ...resumeArgs, @@ -275,6 +284,8 @@ export const POST = withRouteHandler( return new NextResponse(stream, { headers: { ...SSE_HEADERS, + // Echo the negotiated stream protocol (same as the public chat route). + ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), 'X-Execution-Id': enqueueResult.resumeExecutionId, }, }) diff --git a/apps/sim/app/api/skills/[id]/members/route.ts b/apps/sim/app/api/skills/[id]/members/route.ts new file mode 100644 index 00000000000..27920ac9a70 --- /dev/null +++ b/apps/sim/app/api/skills/[id]/members/route.ts @@ -0,0 +1,251 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { skillMember } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { removeSkillMemberContract, upsertSkillMemberContract } from '@/lib/api/contracts/skills' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { getSkillActorContext, listSkillEditors } from '@/lib/skills/access' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('SkillMembersAPI') + +interface RouteContext { + params: Promise<{ id: string }> +} + +type SkillEditorGate = + | { ok: true; workspaceId: string } + | { ok: false; reason: 'not-found' | 'not-editor' } + +/** + * Resolves the skill and asserts the actor can edit it (explicit editor row or + * derived workspace admin). Skills the actor cannot reach at all (missing, + * builtin, no workspace, no workspace access) read as not-found; + * visible-but-not-editor reads as forbidden. + */ +async function requireSkillEditor(skillId: string, userId: string): Promise { + if (isBuiltinSkillId(skillId)) return { ok: false, reason: 'not-found' } + + const actor = await getSkillActorContext(skillId, userId) + if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) { + return { ok: false, reason: 'not-found' } + } + if (!actor.canEdit) return { ok: false, reason: 'not-editor' } + + return { ok: true, workspaceId: actor.skill.workspaceId } +} + +function skillEditorGateResponse(reason: 'not-found' | 'not-editor'): NextResponse { + return reason === 'not-found' + ? NextResponse.json({ error: 'Not found' }, { status: 404 }) + : NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) +} + +export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: skillId } = await context.params + + if (isBuiltinSkillId(skillId)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const actor = await getSkillActorContext(skillId, session.user.id) + if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const editors = await listSkillEditors({ + id: actor.skill.id, + workspaceId: actor.skill.workspaceId, + }) + + return NextResponse.json({ editors }) + } catch (error) { + logger.error('Failed to fetch skill editors', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: skillId } = await context.params + + const gate = await requireSkillEditor(skillId, session.user.id) + if (!gate.ok) { + logger.warn('Skill editor add denied', { + skillId, + actorId: session.user.id, + reason: gate.reason, + }) + return skillEditorGateResponse(gate.reason) + } + + const parsed = await parseRequest(upsertSkillMemberContract, request, context) + if (!parsed.success) return parsed.response + + const { userId } = parsed.data.body + + const targetWorkspacePerm = await getUserEntityPermissions( + userId, + 'workspace', + gate.workspaceId + ) + if (targetWorkspacePerm === null) { + return NextResponse.json({ error: 'User is not a member of this workspace' }, { status: 400 }) + } + if (targetWorkspacePerm === 'admin') { + return NextResponse.json( + { error: 'Workspace admins can always edit skills' }, + { status: 400 } + ) + } + + const [existing] = await db + .select({ id: skillMember.id }) + .from(skillMember) + .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId))) + .limit(1) + + if (existing) { + return NextResponse.json({ success: true }) + } + + const now = new Date() + // Conflict-safe against a concurrent add racing the unique (skillId, userId) index. + const [inserted] = await db + .insert(skillMember) + .values({ + id: generateId(), + skillId, + userId, + invitedBy: session.user.id, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] }) + .returning({ id: skillMember.id }) + + // A concurrent request won the race and created the row. The editor exists, + // so this is still a success — but this request added nothing, and emitting + // the share event or audit entry here would record an add that never happened. + if (!inserted) { + return NextResponse.json({ success: true }) + } + + captureServerEvent( + session.user.id, + 'skill_shared', + { skill_id: skillId, workspace_id: gate.workspaceId }, + { groups: { workspace: gate.workspaceId } } + ) + + recordAudit({ + workspaceId: gate.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SKILL_MEMBER_ADDED, + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + description: 'Added skill editor', + metadata: { targetUserId: userId }, + request, + }) + + return NextResponse.json({ success: true }, { status: 201 }) + } catch (error) { + logger.error('Failed to add skill editor', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: skillId } = await context.params + + const gate = await requireSkillEditor(skillId, session.user.id) + if (!gate.ok) { + logger.warn('Skill editor removal denied', { + skillId, + actorId: session.user.id, + reason: gate.reason, + }) + return skillEditorGateResponse(gate.reason) + } + + const parsed = await parseRequest(removeSkillMemberContract, request, context) + if (!parsed.success) return parsed.response + + const { userId: targetUserId } = parsed.data.query + + const targetWorkspacePerm = await getUserEntityPermissions( + targetUserId, + 'workspace', + gate.workspaceId + ) + if (targetWorkspacePerm === 'admin') { + return NextResponse.json( + { error: 'Workspace admins can always edit skills' }, + { status: 400 } + ) + } + + // Hard delete — no deny markers and no last-editor guard: workspace admins + // always remain derived editors, so a skill can never be orphaned. + const removed = await db + .delete(skillMember) + .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, targetUserId))) + .returning({ id: skillMember.id }) + + if (removed.length === 0) { + return NextResponse.json({ error: 'Editor not found' }, { status: 404 }) + } + + captureServerEvent( + session.user.id, + 'skill_unshared', + { skill_id: skillId, workspace_id: gate.workspaceId }, + { groups: { workspace: gate.workspaceId } } + ) + + recordAudit({ + workspaceId: gate.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SKILL_MEMBER_REMOVED, + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + description: 'Removed skill editor', + metadata: { targetUserId }, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to remove skill editor', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/skills/import/route.ts b/apps/sim/app/api/skills/import/route.ts deleted file mode 100644 index 571f4764078..00000000000 --- a/apps/sim/app/api/skills/import/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { importSkillContract } from '@/lib/api/contracts' -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' - -const logger = createLogger('SkillsImportAPI') - -const FETCH_TIMEOUT_MS = 15_000 - -/** - * Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent. - * - * Supported formats: - * github.com/{owner}/{repo}/blob/{branch}/{path} - * raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} (passthrough) - */ -function toRawGitHubUrl(url: string): string { - const parsed = new URL(url) - - if (parsed.hostname === 'raw.githubusercontent.com') { - return url - } - - if (parsed.hostname !== 'github.com') { - throw new Error('Only GitHub URLs are supported') - } - - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments.length < 5 || segments[2] !== 'blob') { - throw new Error( - 'Invalid GitHub URL format. Expected: https://github.com/{owner}/{repo}/blob/{branch}/{path}' - ) - } - - const [owner, repo, , branch, ...pathParts] = segments - return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}` -} - -/** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */ -export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized skill import attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const validation = await parseRequest(importSkillContract, req, {}) - if (!validation.success) return validation.response - const { url } = validation.data.body - - let rawUrl: string - try { - rawUrl = toRawGitHubUrl(url) - } catch (err) { - const message = getErrorMessage(err, 'Invalid URL') - return NextResponse.json({ error: message }, { status: 400 }) - } - - const response = await fetch(rawUrl, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - headers: { Accept: 'text/plain' }, - }) - - if (!response.ok) { - logger.warn(`[${requestId}] GitHub fetch failed`, { - status: response.status, - url: rawUrl, - }) - return NextResponse.json( - { error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` }, - { status: 502 } - ) - } - - const contentLength = response.headers.get('content-length') - if (contentLength && Number.parseInt(contentLength, 10) > 100_000) { - return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 }) - } - - const content = await response.text() - - if (content.length > 100_000) { - return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 }) - } - - return NextResponse.json({ content }) - } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { - logger.warn(`[${requestId}] GitHub fetch timed out`) - return NextResponse.json({ error: 'Request timed out' }, { status: 504 }) - } - - logger.error(`[${requestId}] Error importing skill`, error) - return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 5a945101be7..f31f0881e71 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -11,9 +11,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { checkSkillsUpdateAccess, getSkillActorContext } from '@/lib/skills/access' import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsAPI') @@ -41,13 +42,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { workspaceId } = query.data - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission) { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.hasAccess) { logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const result = await listSkills({ workspaceId }) + const result = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) const data = result.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) return NextResponse.json({ data }, { status: 200 }) @@ -85,8 +86,40 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { skills, workspaceId, source } = parsed.data.body - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.hasAccess) { + logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) { + return NextResponse.json({ error: 'Built-in skills are read-only' }, { status: 400 }) + } + + // Updating an existing skill requires editor access (explicit editor row + // or derived workspace admin); creating a new one requires workspace write. + const requestedIds = skills.flatMap((s) => (s.id ? [s.id] : [])) + const { existingIds, denied } = await checkSkillsUpdateAccess({ + workspaceId, + userId, + skillIds: requestedIds, + workspaceAccess, + }) + + if (denied.length > 0) { + logger.warn(`[${requestId}] User ${userId} is not an editor of skills being updated`, { + deniedSkillIds: denied.map((s) => s.id), + }) + return NextResponse.json( + { + error: `Skill editor access required to update: ${denied.map((s) => s.name).join(', ')}`, + }, + { status: 403 } + ) + } + + const hasCreates = skills.some((s) => !s.id || !existingIds.has(s.id)) + if (hasCreates && !workspaceAccess.canWrite) { logger.warn( `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` ) @@ -94,11 +127,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } try { - const { skills: resultSkills, touched } = await upsertSkills({ + const { touched } = await upsertSkills({ skills, workspaceId, userId, requestId, + returnSkills: false, }) for (const { id, name, operation } of touched) { @@ -123,11 +157,17 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - return NextResponse.json({ success: true, data: resultSkills }) + const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) + const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) + + return NextResponse.json({ success: true, data }) } catch (upsertError) { - if (upsertError instanceof Error && upsertError.message.includes('already exists')) { + if (upsertError instanceof Error && upsertError.message.includes('is unavailable')) { return NextResponse.json({ error: upsertError.message }, { status: 409 }) } + if (upsertError instanceof Error && upsertError.message.startsWith('Skill not found')) { + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) + } throw upsertError } } catch (error) { @@ -160,12 +200,16 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id: skillId, workspaceId, source } = query.data - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + if (!isBuiltinSkillId(skillId)) { + const actor = await getSkillActorContext(skillId, userId) + if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { + logger.warn(`[${requestId}] Skill not found: ${skillId}`) + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) + } + if (!actor.canEdit) { + logger.warn(`[${requestId}] User ${userId} is not an editor of skill ${skillId}`) + return NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) + } } const deleted = await deleteSkill({ skillId, workspaceId }) diff --git a/apps/sim/app/api/tools/firecrawl/parse/route.ts b/apps/sim/app/api/tools/firecrawl/parse/route.ts index 610df2f45a2..ad88f88da0b 100644 --- a/apps/sim/app/api/tools/firecrawl/parse/route.ts +++ b/apps/sim/app/api/tools/firecrawl/parse/route.ts @@ -88,9 +88,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Firecrawl parse successful`) + const document = firecrawlData.data ?? firecrawlData return NextResponse.json({ success: true, - output: firecrawlData.data ?? firecrawlData, + output: + // Credits reported on the envelope would otherwise be dropped with it, + // leaving a paid parse with nothing to meter. + firecrawlData.creditsUsed != null + ? { ...document, creditsUsed: firecrawlData.creditsUsed } + : document, }) } catch (error) { const notReady = docNotReadyResponse(error) diff --git a/apps/sim/app/api/tools/whatsapp/get-media/route.ts b/apps/sim/app/api/tools/whatsapp/get-media/route.ts new file mode 100644 index 00000000000..30433c71f4c --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/get-media/route.ts @@ -0,0 +1,217 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type WhatsAppGetMediaRouteResponse, + whatsappGetMediaContract, + whatsappGetMediaOutputSchema, +} from '@/lib/api/contracts/tools/whatsapp' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' +import { sanitizeFileName } from '@/executor/constants' +import type { UserFile } from '@/executor/types' +import { + buildMediaUrl, + extractWhatsAppErrorMessage, + WHATSAPP_MEDIA_MAX_BYTES, +} from '@/tools/whatsapp/utils' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const logger = createLogger('WhatsAppGetMediaAPI') + +const MAX_GRAPH_METADATA_BYTES = 256 * 1024 + +/** + * Meta's CDN is reported to reject requests without a conventional User-Agent. + * This is not documented behavior, so it is sent defensively rather than relied upon. + */ +const DOWNLOAD_USER_AGENT = 'SimWhatsAppMedia/1.0' + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies WhatsAppGetMediaRouteResponse + return NextResponse.json(body, { status }) +} + +interface WhatsAppMediaMetadata { + url: string + mimeType: string + fileSize: number | null + sha256: string | null + id: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized WhatsApp media download attempt: ${authResult.error}`) + return failureResponse(authResult.error || 'Authentication required', 401) + } + + const parsed = await parseRequest( + whatsappGetMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { accessToken, mediaId, phoneNumberId, workspaceId, workflowId, executionId } = + parsed.data.body + const authorization = `Bearer ${accessToken.trim()}` + + try { + const metadataResponse = await fetch(buildMediaUrl(mediaId, phoneNumberId), { + headers: { Authorization: authorization }, + signal: request.signal, + }) + + const metadataBody = await readResponseJsonWithLimit>( + metadataResponse, + { + maxBytes: MAX_GRAPH_METADATA_BYTES, + label: `WhatsApp media ${mediaId} metadata`, + signal: request.signal, + } + ) + + if (!metadataResponse.ok) { + const message = extractWhatsAppErrorMessage(metadataBody, metadataResponse.status) + logger.error(`[${requestId}] WhatsApp media lookup failed`, { + status: metadataResponse.status, + }) + return failureResponse( + message, + metadataResponse.status >= 400 && metadataResponse.status < 500 + ? metadataResponse.status + : 502 + ) + } + + const url = typeof metadataBody.url === 'string' ? metadataBody.url : undefined + if (!url) { + return failureResponse('WhatsApp media metadata did not include a download URL', 502) + } + + // file_size comes back as a string in some responses, so coerce before comparing. + const parsedSize = Number(metadataBody.file_size) + const metadata: WhatsAppMediaMetadata = { + url, + mimeType: + typeof metadataBody.mime_type === 'string' && metadataBody.mime_type.length > 0 + ? metadataBody.mime_type + : 'application/octet-stream', + fileSize: Number.isFinite(parsedSize) ? parsedSize : null, + sha256: typeof metadataBody.sha256 === 'string' ? metadataBody.sha256 : null, + id: typeof metadataBody.id === 'string' ? metadataBody.id : mediaId, + } + + // Reject oversized media from the declared size before spending bandwidth on it. + if (metadata.fileSize !== null && metadata.fileSize > WHATSAPP_MEDIA_MAX_BYTES) { + return failureResponse( + `WhatsApp media is ${(metadata.fileSize / (1024 * 1024)).toFixed(2)} MB, which exceeds the 100 MB download limit`, + 413 + ) + } + + // The media URL points at Meta's CDN and still requires the bearer token. + const urlValidation = await validateUrlWithDNS(metadata.url, 'mediaUrl') + if (!urlValidation.isValid) { + return failureResponse(`Invalid WhatsApp media URL: ${urlValidation.error}`, 502) + } + + const mediaResponse = await secureFetchWithPinnedIP(metadata.url, urlValidation.resolvedIP!, { + method: 'GET', + headers: { + Authorization: authorization, + 'User-Agent': DOWNLOAD_USER_AGENT, + }, + maxResponseBytes: WHATSAPP_MEDIA_MAX_BYTES, + stripAuthOnRedirect: true, + signal: request.signal, + }) + + if (!mediaResponse.ok) { + logger.error(`[${requestId}] WhatsApp media download failed`, { + status: mediaResponse.status, + }) + return failureResponse( + mediaResponse.status === 404 + ? 'WhatsApp media not found or its download URL expired (URLs are valid for 5 minutes)' + : `Failed to download WhatsApp media (${mediaResponse.status})`, + mediaResponse.status >= 400 && mediaResponse.status < 500 ? mediaResponse.status : 502 + ) + } + + const buffer = await readResponseToBufferWithLimit(mediaResponse, { + maxBytes: WHATSAPP_MEDIA_MAX_BYTES, + label: 'WhatsApp media download', + }) + + const extension = getExtensionFromMimeType(metadata.mimeType) ?? 'bin' + const fileName = sanitizeFileName(`whatsapp-${metadata.id}.${extension}`) + + const file: UserFile = + workspaceId && workflowId && executionId + ? await uploadExecutionFile( + { workspaceId, workflowId, executionId }, + buffer, + fileName, + metadata.mimeType, + authResult.userId + ) + : await uploadCopilotFile({ + buffer, + fileName, + contentType: metadata.mimeType, + userId: authResult.userId, + }) + + logger.info(`[${requestId}] WhatsApp media downloaded`, { + mediaId: metadata.id, + mimeType: metadata.mimeType, + size: buffer.length, + }) + + const output = whatsappGetMediaOutputSchema.parse({ + file, + mediaId: metadata.id, + mimeType: metadata.mimeType, + fileSize: buffer.length, + sha256: metadata.sha256, + }) + + return NextResponse.json({ + success: true, + output, + } satisfies WhatsAppGetMediaRouteResponse) + } catch (error) { + logger.error(`[${requestId}] WhatsApp media download failed`, { error }) + + if (isPayloadSizeLimitError(error)) { + return failureResponse('WhatsApp media exceeds the 100 MB download limit', 413) + } + + return failureResponse(getErrorMessage(error, 'Failed to download WhatsApp media'), 500) + } +}) diff --git a/apps/sim/app/api/tools/whatsapp/send-media/route.ts b/apps/sim/app/api/tools/whatsapp/send-media/route.ts new file mode 100644 index 00000000000..cd611dc809c --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/send-media/route.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type WhatsAppSendMediaRouteResponse, + whatsappSendMediaContract, +} from '@/lib/api/contracts/tools/whatsapp' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadWhatsAppMedia } from '@/app/api/tools/whatsapp/upload.server' +import { + buildAuthHeaders, + buildMediaMessageBody, + buildMessagesUrl, + transformWhatsAppSendResponse, +} from '@/tools/whatsapp/utils' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const logger = createLogger('WhatsAppSendMediaAPI') + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies WhatsAppSendMediaRouteResponse + return NextResponse.json(body, { status }) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized WhatsApp media send attempt: ${authResult.error}`) + return failureResponse(authResult.error || 'Authentication required', 401) + } + + const parsed = await parseRequest( + whatsappSendMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + const suppliedSources = [body.file, body.mediaId, body.mediaLink].filter(Boolean).length + if (suppliedSources === 0) { + return failureResponse('Provide a file, a media ID, or a media link', 400) + } + if (suppliedSources > 1) { + return failureResponse('Provide only one of file, media ID, or media link', 400) + } + + try { + // A dropped file has no WhatsApp identity yet, so upload it first and send the + // resulting media ID. Media persists 30 days, so the ID is returned for reuse. + let uploadedMediaId: string | undefined + let filename = body.filename ?? undefined + + if (body.file) { + const uploaded = await uploadWhatsAppMedia({ + file: body.file, + accessToken: body.accessToken, + phoneNumberId: body.phoneNumberId, + userId: authResult.userId, + requestId, + logger, + signal: request.signal, + }) + + if (!uploaded.ok) { + return 'response' in uploaded + ? uploaded.response + : failureResponse(uploaded.error, uploaded.status) + } + + uploadedMediaId = uploaded.media.mediaId + filename = filename ?? uploaded.media.fileName + } + + const messageBody = buildMediaMessageBody({ + phoneNumber: body.phoneNumber, + mediaType: body.mediaType, + mediaId: uploadedMediaId ?? body.mediaId ?? undefined, + mediaLink: body.mediaLink ?? undefined, + caption: body.caption ?? undefined, + filename, + }) + + const response = await fetch(buildMessagesUrl(body.phoneNumberId), { + method: 'POST', + headers: buildAuthHeaders(body.accessToken), + body: JSON.stringify(messageBody), + signal: request.signal, + }) + + const sendResult = await transformWhatsAppSendResponse(response) + + // transformWhatsAppSendResponse throws on a non-OK send, so reaching here means success. + return NextResponse.json({ + success: true, + output: { + ...sendResult.output, + success: true, + messageId: sendResult.output.messageId ?? '', + inputPhoneNumber: sendResult.output.inputPhoneNumber ?? null, + whatsappUserId: sendResult.output.whatsappUserId ?? null, + contacts: sendResult.output.contacts ?? [], + ...(uploadedMediaId ? { mediaId: uploadedMediaId } : {}), + }, + } satisfies WhatsAppSendMediaRouteResponse) + } catch (error) { + logger.error(`[${requestId}] WhatsApp media send failed`, { error }) + return failureResponse( + getErrorMessage(error, 'Failed to send WhatsApp media'), + isPayloadSizeLimitError(error) ? 413 : 500 + ) + } +}) diff --git a/apps/sim/app/api/tools/whatsapp/upload-media/route.ts b/apps/sim/app/api/tools/whatsapp/upload-media/route.ts new file mode 100644 index 00000000000..befa0f34d22 --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/upload-media/route.ts @@ -0,0 +1,73 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type WhatsAppUploadMediaRouteResponse, + whatsappUploadMediaContract, +} from '@/lib/api/contracts/tools/whatsapp' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadWhatsAppMedia } from '@/app/api/tools/whatsapp/upload.server' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const logger = createLogger('WhatsAppUploadMediaAPI') + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies WhatsAppUploadMediaRouteResponse + return NextResponse.json(body, { status }) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized WhatsApp media upload attempt: ${authResult.error}`) + return failureResponse(authResult.error || 'Authentication required', 401) + } + + const parsed = await parseRequest( + whatsappUploadMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { accessToken, phoneNumberId, file } = parsed.data.body + + try { + const result = await uploadWhatsAppMedia({ + file, + accessToken, + phoneNumberId, + userId: authResult.userId, + requestId, + logger, + signal: request.signal, + }) + + if (!result.ok) { + return 'response' in result ? result.response : failureResponse(result.error, result.status) + } + + return NextResponse.json({ + success: true, + output: result.media, + } satisfies WhatsAppUploadMediaRouteResponse) + } catch (error) { + logger.error(`[${requestId}] WhatsApp media upload failed`, { error }) + return failureResponse( + getErrorMessage(error, 'Failed to upload media to WhatsApp'), + isPayloadSizeLimitError(error) ? 413 : 500 + ) + } +}) diff --git a/apps/sim/app/api/tools/whatsapp/upload.server.ts b/apps/sim/app/api/tools/whatsapp/upload.server.ts new file mode 100644 index 00000000000..ea84d2d5ab5 --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/upload.server.ts @@ -0,0 +1,148 @@ +import type { Logger } from '@sim/logger' +import type { NextResponse } from 'next/server' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { RawFileInput } from '@/lib/uploads/utils/file-utils' +import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + buildMediaUploadUrl, + extractWhatsAppErrorMessage, + whatsappMediaLimitFor, +} from '@/tools/whatsapp/utils' + +/** WhatsApp error and upload envelopes are small; cap the read so a hostile body cannot balloon memory. */ +const MAX_GRAPH_RESPONSE_BYTES = 256 * 1024 + +export interface UploadedWhatsAppMedia { + mediaId: string + fileName: string + mimeType: string + size: number +} + +export type UploadWhatsAppMediaResult = + | { ok: true; media: UploadedWhatsAppMedia } + | { ok: false; error: string; status: number } + | { ok: false; response: NextResponse } + +/** + * Pull a stored user file, enforce WhatsApp's documented per-type size ceiling, and + * upload it to `/{phone-number-id}/media`. Shared by the standalone Upload Media + * operation and the file path of Send Media. + */ +export async function uploadWhatsAppMedia({ + file, + accessToken, + phoneNumberId, + userId, + requestId, + logger, + signal, +}: { + file: RawFileInput + accessToken: string + phoneNumberId: string + userId: string + requestId: string + logger: Logger + signal?: AbortSignal +}): Promise { + const userFile = processSingleFileToUserFile(file, requestId, logger) + if (!userFile) { + return { ok: false, error: 'No valid file provided for upload', status: 400 } + } + + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return { ok: false, response: denied } + + // Check the declared size against WhatsApp's ceiling before pulling bytes, then cap + // the storage read itself so a mis-declared size cannot blow past it. + const declaredMimeType = userFile.type || 'application/octet-stream' + const declaredLimit = whatsappMediaLimitFor(declaredMimeType) + if (userFile.size > declaredLimit.maxBytes) { + return { + ok: false, + error: `${userFile.name} is ${(userFile.size / (1024 * 1024)).toFixed(2)} MB, which exceeds WhatsApp's limit for ${declaredLimit.label}`, + status: 413, + } + } + + let buffer: Buffer + let contentType: string + try { + const downloaded = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: declaredLimit.maxBytes, + signal, + }) + buffer = downloaded.buffer + contentType = downloaded.contentType + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return { ok: false, response: notReady } + throw error + } + + // downloadServableFileFromStorage can swap an AI-generated doc for its compiled + // artifact, so re-resolve the limit against the type actually being sent. + const resolvedMimeType = contentType || declaredMimeType + const resolvedLimit = whatsappMediaLimitFor(resolvedMimeType) + if (buffer.length > resolvedLimit.maxBytes) { + return { + ok: false, + error: `${userFile.name} is ${(buffer.length / (1024 * 1024)).toFixed(2)} MB, which exceeds WhatsApp's limit for ${resolvedLimit.label}`, + status: 413, + } + } + + const formData = new FormData() + formData.append('messaging_product', 'whatsapp') + formData.append('type', resolvedMimeType) + formData.append( + 'file', + new Blob([new Uint8Array(buffer)], { type: resolvedMimeType }), + userFile.name + ) + + logger.info(`[${requestId}] Uploading media to WhatsApp`, { + fileName: userFile.name, + mimeType: resolvedMimeType, + size: buffer.length, + }) + + // Content-Type is intentionally omitted so fetch sets the multipart boundary. + const response = await fetch(buildMediaUploadUrl(phoneNumberId), { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken.trim()}` }, + body: formData, + signal, + }) + + const data = await readResponseJsonWithLimit>(response, { + maxBytes: MAX_GRAPH_RESPONSE_BYTES, + label: 'WhatsApp media upload response', + signal, + }) + + if (!response.ok) { + logger.error(`[${requestId}] WhatsApp media upload failed`, { status: response.status }) + return { + ok: false, + error: extractWhatsAppErrorMessage(data, response.status), + status: response.status >= 400 && response.status < 500 ? response.status : 502, + } + } + + const mediaId = typeof data.id === 'string' ? data.id : undefined + if (!mediaId) { + return { ok: false, error: 'WhatsApp upload response did not include a media ID', status: 502 } + } + + logger.info(`[${requestId}] WhatsApp media uploaded`, { mediaId }) + + return { + ok: true, + media: { mediaId, fileName: userFile.name, mimeType: resolvedMimeType, size: buffer.length }, + } +} diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index fc3082427ab..bcc8df9dcfb 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -34,6 +34,7 @@ import { import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -286,6 +287,7 @@ export const DELETE = withRouteHandler( await tx.delete(permissions).where(eq(permissions.id, memberId)) await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId) + await removeWorkspaceSkillMembershipsTx(tx, workspaceId, existingMember.userId) }) logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts index 0be8954b693..85523cd6c11 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -45,6 +45,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -371,6 +372,7 @@ export const DELETE = withRouteHandler( await tx.delete(permissions).where(eq(permissions.id, existingPermission.id)) await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) + await removeWorkspaceSkillMembershipsTx(tx, workspaceId, userId) }) logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index d94b3abcc2a..fdfe2fdc166 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -74,6 +74,8 @@ describe('Workflow Chat Status Route', () => { authType: 'public', allowedEmails: [], outputConfigs: [{ blockId: 'agent-1', path: 'content' }], + includeThinking: true, + includeToolCalls: null, password: 'secret', isActive: true, }, @@ -88,5 +90,7 @@ describe('Workflow Chat Status Route', () => { expect(data.deployment.id).toBe('chat-1') expect(data.deployment.hasPassword).toBe(true) expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }]) + expect(data.deployment.includeThinking).toBe(true) + expect(data.deployment.includeToolCalls).toBe(true) }) }) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index 49d555b813d..afa5f6ce6a7 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -52,6 +52,8 @@ export const GET = withRouteHandler( authType: chat.authType, allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, password: chat.password, isActive: chat.isActive, }) @@ -71,6 +73,11 @@ export const GET = withRouteHandler( authType: deploymentResults[0].authType, allowedEmails: deploymentResults[0].allowedEmails, outputConfigs: deploymentResults[0].outputConfigs, + includeThinking: deploymentResults[0].includeThinking ?? false, + includeToolCalls: + deploymentResults[0].includeToolCalls ?? + deploymentResults[0].includeThinking ?? + false, hasPassword: Boolean(deploymentResults[0].password), } : null diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 49f50b3ae21..0ba83187d81 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -71,7 +71,11 @@ import { import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' -import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events' +import { + type ExecutionEvent, + encodeSSEEvent, + LIVE_ONLY_EXECUTION_EVENT_TYPES, +} from '@/lib/workflows/executor/execution-events' import { claimExecutionId, type ExecutionIdClaim, @@ -84,6 +88,10 @@ import { loadWorkflowDeploymentVersionState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' +import { + forwardAgentStreamToExecutionEvents, + shouldForwardAnswerTextFromSink, +} from '@/lib/workflows/streaming/forward-agent-stream-events' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' @@ -1437,6 +1445,9 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, timeoutMs: preprocessResult.executionTimeout?.sync, + /** Workflow API has no deployed-chat event policies, so agent-event frames stay off. */ + includeThinking: false, + includeToolCalls: false, }, executionId, largeValueExecutionIds, @@ -1446,6 +1457,8 @@ async function handleExecutePost( workflowId, userId: actorUserId, allowLargeValueWorkflowScope, + requestSignal: req.signal, + requestHeaders: req.headers, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => executeWorkflow( streamWorkflow, @@ -1518,7 +1531,7 @@ async function handleExecutePost( event: ExecutionEvent, terminalStatus?: TerminalExecutionStreamStatus ) => { - const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done' + const isBuffered = !LIVE_ONLY_EXECUTION_EVENT_TYPES.has(event.type) let eventToSend = event if (isBuffered) { try { @@ -1716,6 +1729,21 @@ async function handleExecutePost( const onStream = async (streamingExec: StreamingExecution) => { const blockId = (streamingExec.execution as any).blockId + // Live answer text rides the sink when available (pending deltas + // stream as the model generates; chunk_reset clears intermediate + // turns). The byte stream is then drained without re-emitting + // chunks — its text is the same final-turn content. + const answerTextFromSink = shouldForwardAnswerTextFromSink(streamingExec) + + // Sync window: attach sink before first await so pump delivers thinking/tools. + const unsubscribe = forwardAgentStreamToExecutionEvents(streamingExec, { + blockId, + executionId, + workflowId, + sendEvent, + forwardAnswerText: answerTextFromSink, + }) + const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() const cancelReader = () => { @@ -1732,6 +1760,8 @@ async function handleExecutePost( if (timeoutController.signal.aborted || isStreamClosed) break if (done) break + if (answerTextFromSink) continue + const chunk = decoder.decode(value, { stream: true }) await sendEvent({ type: 'stream:chunk', @@ -1756,6 +1786,7 @@ async function handleExecutePost( reqLogger.error('Error streaming block content:', error) } } finally { + unsubscribe() timeoutController.signal.removeEventListener('abort', cancelReader) try { await reader.cancel().catch(() => {}) @@ -1785,6 +1816,8 @@ async function handleExecutePost( allowLargeValueWorkflowScope, callChain, executionMode: 'sync', + // Canvas execution-events runs are the primary agent-events surface. + agentEvents: true, } const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {} diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index eef2c1b9af0..da54edf8957 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' -import { isE2BDocEnabled } from '@/lib/core/config/env-flags' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' @@ -57,7 +57,7 @@ export const GET = withRouteHandler( // In the E2B regime ALL four formats compile in the doc sandbox (Node for // pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so // a stale file can't trigger an E2B compile when the sandbox is disabled. - const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileRecord.name) : null const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) { diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 51a1e038c63..5472850c5e6 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -12,6 +12,7 @@ import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -144,6 +145,7 @@ export const DELETE = withRouteHandler( ) await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) + await removeWorkspaceSkillMembershipsTx(tx, workspaceId, userId) return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx index f66a45b2335..0a7a331deb9 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx @@ -1,26 +1,14 @@ 'use client' - -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' + type AddPeopleTarget, + type MemberRole, + AddPeopleModal as SharedAddPeopleModal, +} from '@/components/permissions' import { useUpsertWorkspaceCredentialMember, useWorkspaceCredentialMembers, - type WorkspaceCredentialRole, } from '@/hooks/queries/credentials' -import { ROLE_OPTIONS } from '../roles' -import { partitionSettledFailures, resolveAddEmail } from '../sharing' - -const logger = createLogger('AddPeopleModal') interface AddPeopleModalProps { credentialId: string @@ -29,28 +17,12 @@ interface AddPeopleModalProps { } /** - * Shared "Add people" modal: grants existing workspace members access to a - * credential with a chosen role. Emails are validated against the workspace - * roster and current membership; each add is an idempotent upsert and partial - * failures keep only the people that still need adding. + * "Add people" for a credential: wires the shared modal to credential + * membership. Active members count as already having access. */ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) { - const { workspacePermissions } = useWorkspacePermissionsContext() const { data: members = [] } = useWorkspaceCredentialMembers(credentialId) - const upsertMember = useUpsertWorkspaceCredentialMember() - - const [emailsToAdd, setEmailsToAdd] = useState([]) - const [roleToAdd, setRoleToAdd] = useState('member') - const [isAdding, setIsAdding] = useState(false) - const [submitError, setSubmitError] = useState(null) - - const workspaceUserIdByEmail = useMemo( - () => - new Map( - (workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId]) - ), - [workspacePermissions?.users] - ) + const { mutateAsync: upsertMemberAsync } = useUpsertWorkspaceCredentialMember() const existingMemberEmails = useMemo( () => @@ -63,108 +35,18 @@ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleMo [members] ) - const validateAddEmail = useCallback( - (email: string): string | null => { - const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) - return 'error' in result ? result.error : null - }, - [workspaceUserIdByEmail, existingMemberEmails] + const addMember = useCallback( + (target: AddPeopleTarget, role: MemberRole) => + upsertMemberAsync({ credentialId, userId: target.userId, role }), + [upsertMemberAsync, credentialId] ) - const handleClose = useCallback(() => { - setEmailsToAdd([]) - setRoleToAdd('member') - setSubmitError(null) - onOpenChange(false) - }, [onOpenChange]) - - const handleAddPeople = useCallback(async () => { - if (emailsToAdd.length === 0 || isAdding) return - setSubmitError(null) - const targets = emailsToAdd - .map((email) => { - const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) - return 'userId' in result ? { email, userId: result.userId } : null - }) - .filter((target): target is { email: string; userId: string } => target !== null) - if (targets.length === 0) return - - setIsAdding(true) - try { - const results = await Promise.allSettled( - targets.map((target) => - upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd }) - ) - ) - const failures = partitionSettledFailures(targets, results) - if (failures.length === 0) { - handleClose() - return - } - setEmailsToAdd(failures.map((target) => target.email)) - const firstError = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - logger.error('Failed to add some credential members', firstError?.reason) - const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.') - setSubmitError( - failures.length === targets.length - ? `Couldn't add people. ${reason}` - : `Couldn't add ${failures.length} of ${targets.length} people. ${reason}` - ) - } finally { - setIsAdding(false) - } - }, [ - credentialId, - emailsToAdd, - isAdding, - workspaceUserIdByEmail, - existingMemberEmails, - roleToAdd, - upsertMember, - handleClose, - ]) - return ( - { - if (!next) handleClose() - }} - srTitle='Add people' - > - Add people - - - setRoleToAdd(role as WorkspaceCredentialRole)} - disabled={isAdding} - /> - {submitError} - - - + onOpenChange={onOpenChange} + existingMemberEmails={existingMemberEmails} + addMember={addMember} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx index 2d8a0e9c548..b08907f2cc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx @@ -1,15 +1,16 @@ 'use client' -import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn' import { createLogger } from '@sim/logger' -import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions' -import { getUserColor } from '@/lib/workspaces/colors' +import { + credentialRoleLockReason, + MEMBER_ROLE_OPTIONS, + type MemberRole, + MemberRow, +} from '@/components/permissions' import { useRemoveWorkspaceCredentialMember, useUpsertWorkspaceCredentialMember, useWorkspaceCredentialMembers, - type WorkspaceCredentialRole, } from '@/hooks/queries/credentials' -import { ROLE_OPTIONS } from '../roles' import { DetailSection } from './detail-section' const logger = createLogger('CredentialMembersSection') @@ -35,7 +36,7 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe (member) => member.role === 'admin' && member.roleSource !== 'workspace-admin' ).length - const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => { + const handleChangeMemberRole = async (userId: string, role: MemberRole) => { const current = activeMembers.find((member) => member.userId === userId) if (current?.role === role) return try { @@ -63,58 +64,18 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe member.role === 'admin' && member.roleSource !== 'workspace-admin' && explicitAdminCount <= 1 - const roleDisabled = !isAdmin || roleLocked || lockReason !== null - const removeDisabled = roleLocked || lockReason !== null return ( -
-
- - - {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} - - -
- - {member.userName || member.userEmail || member.userId} - - - {member.userEmail || member.userId} - -
-
- - - handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole) - } - /> - - {isAdmin && ( - handleRemoveMember(member.userId)} - disabled={removeDisabled} - flush - className='justify-self-end' - > - Remove - - )} -
+ member={member} + roleOptions={MEMBER_ROLE_OPTIONS} + lockReason={lockReason} + canManage={isAdmin} + roleDisabled={!isAdmin || roleLocked || lockReason !== null} + removeDisabled={roleLocked || lockReason !== null} + onRoleChange={(role) => handleChangeMemberRole(member.userId, role)} + onRemove={() => handleRemoveMember(member.userId)} + /> ) })}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts deleted file mode 100644 index 2956f8ed07d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { WorkspaceCredentialRole } from '@/hooks/queries/credentials' - -export interface CredentialRoleOption { - value: WorkspaceCredentialRole - label: string -} - -/** - * Roles assignable to a credential member. Shared by every credential detail - * surface (Integrations, Secrets) so role choices never drift between them. - */ -export const ROLE_OPTIONS: readonly CredentialRoleOption[] = [ - { value: 'member', label: 'Member' }, - { value: 'admin', label: 'Admin' }, -] as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx new file mode 100644 index 00000000000..9290ca41b2b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx @@ -0,0 +1,380 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { + ChipTag, + CODE_LINE_HEIGHT_PX, + Popover, + PopoverAnchor, + PopoverContent, + PopoverItem, + PopoverScrollArea, + PopoverSection, +} from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { + CODE_PLACEHOLDER, + type SchemaParameter, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +import type { useCodeGeneration } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation' +import { + checkEnvVarTrigger, + EnvVarDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' +import { + checkTagTrigger, + TagDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor' + +const logger = createLogger('CustomToolCodeField') + +interface CustomToolCodeFieldProps { + value: string + onChange: (value: string) => void + error: boolean + generation: ReturnType + schemaParameters: SchemaParameter[] + workspaceId: string + /** + * Workflow block the editor is embedded in. Only present on the canvas — + * without it there is no upstream block output to reference, so the `<` + * tag autocomplete is not offered. + */ + blockId?: string + /** Renders the editor inert for viewers without edit rights. */ + disabled?: boolean +} + +interface TriggerState { + show: boolean + searchTerm: string +} + +/** Trailing identifier under the caret — the unit both the trigger and the completion act on. */ +const SCHEMA_PARAM_WORD = /[a-zA-Z_]\w*$/ + +function checkSchemaParamTrigger( + text: string, + cursorPos: number, + parameters: SchemaParameter[] +): TriggerState { + if (parameters.length === 0) return { show: false, searchTerm: '' } + + const currentWord = text.slice(0, cursorPos).match(SCHEMA_PARAM_WORD)?.[0] ?? '' + if (!currentWord) return { show: false, searchTerm: '' } + + const lower = currentWord.toLowerCase() + const hasMatch = parameters.some((param) => param.name.toLowerCase().startsWith(lower)) + return { show: hasMatch, searchTerm: currentWord } +} + +/** + * The code half of the custom tool editor: the available-parameters strip, the + * JavaScript editor, and its three caret-anchored autocompletes (environment + * variables, upstream block tags, and schema parameters). The surrounding + * surface owns the section label, the "Generate" action, and the error message. + */ +export function CustomToolCodeField({ + value, + onChange, + error, + generation, + schemaParameters, + workspaceId, + blockId, + disabled = false, +}: CustomToolCodeFieldProps) { + const codeEditorRef = useRef(null) + const schemaParamItemRefs = useRef | null>(null) + schemaParamItemRefs.current ??= new Map() + + const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) + const [showSchemaParams, setShowSchemaParams] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [cursorPosition, setCursorPosition] = useState(0) + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }) + const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0) + + const busy = disabled || generation.isLoading || generation.isStreaming + const resolvedMinHeight = schemaParameters.length > 0 ? '380px' : '420px' + + /** Generation writes bypass `handleChange`, so close any open menu here instead. */ + useEffect(() => { + if (!busy) return + setShowEnvVars(false) + setShowTags(false) + setShowSchemaParams(false) + }, [busy]) + + useEffect(() => { + if (!showSchemaParams || schemaParamSelectedIndex < 0) return + const element = schemaParamItemRefs.current?.get(schemaParamSelectedIndex) + element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, [schemaParamSelectedIndex, showSchemaParams]) + + const handleChange = (newValue: string) => { + onChange(newValue) + if (busy) return + + const container = codeEditorRef.current + const textarea = container?.querySelector('textarea') + if (!container || !textarea) return + + const pos = textarea.selectionStart + setCursorPosition(pos) + + const textBeforeCursor = newValue.substring(0, pos) + const lines = textBeforeCursor.split('\n') + const currentLine = lines.length + const currentCol = lines[lines.length - 1].length + + const editorRect = container.getBoundingClientRect() + setDropdownPosition({ + top: currentLine * CODE_LINE_HEIGHT_PX + 5, + left: Math.min(currentCol * 8, editorRect.width - 260), + }) + + const envVarTrigger = checkEnvVarTrigger(newValue, pos) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') + + if (blockId) { + const tagTrigger = checkTagTrigger(newValue, pos) + setShowTags(tagTrigger.show) + if (!tagTrigger.show) setActiveSourceBlockId(null) + } + + if (schemaParameters.length > 0) { + const schemaParamTrigger = checkSchemaParamTrigger(newValue, pos, schemaParameters) + if (schemaParamTrigger.show && !showSchemaParams) { + setShowSchemaParams(true) + setSchemaParamSelectedIndex(0) + } else if (!schemaParamTrigger.show && showSchemaParams) { + setShowSchemaParams(false) + } + } + } + + const handleSchemaParamSelect = (paramName: string) => { + const textarea = codeEditorRef.current?.querySelector('textarea') + if (!textarea) return + + const pos = textarea.selectionStart + const beforeCursor = value.substring(0, pos) + const afterCursor = value.substring(pos) + + // Must match checkSchemaParamTrigger's boundary exactly — a looser split + // here would replace text the trigger never matched (e.g. eat `data.`). + const currentWord = beforeCursor.match(SCHEMA_PARAM_WORD)?.[0] ?? '' + const wordStart = pos - currentWord.length + + onChange(beforeCursor.substring(0, wordStart) + paramName + afterCursor) + setShowSchemaParams(false) + + requestAnimationFrame(() => { + textarea.focus() + const caret = wordStart + paramName.length + textarea.setSelectionRange(caret, caret) + }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + if (showEnvVars || showTags || showSchemaParams) { + setShowEnvVars(false) + setShowTags(false) + setShowSchemaParams(false) + e.preventDefault() + e.stopPropagation() + return + } + } + + if (generation.isStreaming) { + e.preventDefault() + return + } + + if (showSchemaParams && schemaParameters.length > 0) { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + e.stopPropagation() + setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1)) + return + case 'ArrowUp': + e.preventDefault() + e.stopPropagation() + setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0)) + return + case 'Enter': { + e.preventDefault() + e.stopPropagation() + const selectedParam = schemaParameters[schemaParamSelectedIndex] + if (selectedParam) handleSchemaParamSelect(selectedParam.name) + return + } + case 'Escape': + e.preventDefault() + e.stopPropagation() + setShowSchemaParams(false) + return + case ' ': + case 'Tab': + setShowSchemaParams(false) + return + } + } + + if (showEnvVars || showTags) { + if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) { + e.preventDefault() + e.stopPropagation() + } + } + } + + return ( +
+ {schemaParameters.length > 0 && ( +
+ Available parameters: + {schemaParameters.map((param) => ( + + {param.name} + + ))} + + Start typing a parameter name for autocomplete. + +
+ )} + +
+ + + {showEnvVars && ( + { + onChange(newValue) + setShowEnvVars(false) + }} + searchTerm={searchTerm} + inputValue={value} + cursorPosition={cursorPosition} + workspaceId={workspaceId} + onClose={() => { + setShowEnvVars(false) + setSearchTerm('') + }} + className='w-64' + style={{ + position: 'absolute', + top: `${dropdownPosition.top}px`, + left: `${dropdownPosition.left}px`, + }} + /> + )} + + {showTags && blockId && ( + { + onChange(newValue) + setShowTags(false) + setActiveSourceBlockId(null) + }} + blockId={blockId} + activeSourceBlockId={activeSourceBlockId} + inputValue={value} + cursorPosition={cursorPosition} + onClose={() => { + setShowTags(false) + setActiveSourceBlockId(null) + }} + className='w-64' + style={{ + position: 'absolute', + top: `${dropdownPosition.top}px`, + left: `${dropdownPosition.left}px`, + }} + /> + )} + + {showSchemaParams && schemaParameters.length > 0 && ( + { + if (!open) setShowSchemaParams(false) + }} + colorScheme='inverted' + > + +
+ + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + Available Parameters + {schemaParameters.map((param, index) => ( + setSchemaParamSelectedIndex(index)} + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleSchemaParamSelect(param.name) + }} + ref={(el) => { + if (el) schemaParamItemRefs.current?.set(index, el) + }} + > + {param.name} + {param.type && param.type !== 'any' && ( + + {param.type} + + )} + + ))} + + + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field.tsx new file mode 100644 index 00000000000..2fb38059dd4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field.tsx @@ -0,0 +1,41 @@ +'use client' + +import { SCHEMA_PLACEHOLDER } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +import type { useSchemaGeneration } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation' +import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor' + +interface CustomToolSchemaFieldProps { + value: string + onChange: (value: string) => void + error: boolean + generation: ReturnType + /** Renders the editor inert for viewers without edit rights. */ + disabled?: boolean +} + +/** + * The JSON-schema half of the custom tool editor. The surrounding surface owns + * the section label, the "Generate" action, and the error message — this field + * is just the editor, so both consumers can frame it however they need. + */ +export function CustomToolSchemaField({ + value, + onChange, + error, + generation, + disabled = false, +}: CustomToolSchemaFieldProps) { + const busy = disabled || generation.isLoading || generation.isStreaming + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema.ts b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema.ts new file mode 100644 index 00000000000..542709bf2d6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema.ts @@ -0,0 +1,126 @@ +/** + * Shared parsing/validation for a custom tool's OpenAI function-calling JSON + * schema. Used by both custom-tool editing surfaces (the canvas modal and the + * Settings > Custom Tools detail page) so they agree on what a valid schema is. + */ + +export interface SchemaParameter { + name: string + type: string + description: string + required: boolean +} + +interface SchemaValidation { + isValid: boolean + error: string | null +} + +export const SCHEMA_PLACEHOLDER = `{ + "type": "function", + "function": { + "name": "addItemToOrder", + "description": "Add one quantity of a food item to the order.", + "parameters": { + "type": "object", + "properties": { + "itemName": { + "type": "string", + "description": "The name of the food item to add to order" + } + }, + "required": ["itemName"] + } + } +}` + +export const CODE_PLACEHOLDER = 'return schemaVariable + {{environmentVariable}}' + +/** Shown when the server rejects a rename — `function.name` is immutable after creation. */ +export const FUNCTION_NAME_LOCKED = + 'Function name cannot be changed after creation. To use a different name, delete this tool and create a new one.' + +/** Delete-confirmation copy, shared by both editing surfaces. */ +export const CUSTOM_TOOL_DELETE_CONFIRM_TEXT = [ + { + text: 'This will permanently delete the tool and remove it from any workflows that are using it.', + error: true, + }, + ' This action cannot be undone.', +] as const + +/** Validates the shape the executor and providers expect. */ +export function validateCustomToolSchema(schema: string): SchemaValidation { + if (!schema) return { isValid: false, error: null } + + try { + const parsed = JSON.parse(schema) + + if (!parsed.type || parsed.type !== 'function') { + return { isValid: false, error: 'Missing "type": "function"' } + } + if (!parsed.function || !parsed.function.name) { + return { isValid: false, error: 'Missing function.name field' } + } + if (!parsed.function.parameters) { + return { isValid: false, error: 'Missing function.parameters object' } + } + if (!parsed.function.parameters.type) { + return { isValid: false, error: 'Missing parameters.type field' } + } + if (parsed.function.parameters.properties === undefined) { + return { isValid: false, error: 'Missing parameters.properties field' } + } + if ( + typeof parsed.function.parameters.properties !== 'object' || + parsed.function.parameters.properties === null + ) { + return { isValid: false, error: 'parameters.properties must be an object' } + } + + return { isValid: true, error: null } + } catch { + return { isValid: false, error: 'Invalid JSON format' } + } +} + +/** + * The tool's identity as declared inside its schema. Name and description have + * no separate storage — `schema.function.name` IS the tool's title (the save + * path derives it), so surfaces read them back out rather than offering a second + * place to edit them. + */ +export function extractSchemaIdentity(jsonSchema: string): { + name: string | null + description: string | null +} { + try { + const fn = JSON.parse(jsonSchema)?.function + return { + name: typeof fn?.name === 'string' && fn.name ? fn.name : null, + description: typeof fn?.description === 'string' && fn.description ? fn.description : null, + } + } catch { + return { name: null, description: null } + } +} + +/** Flattens a schema's properties into the parameter list the code editor autocompletes against. */ +export function extractSchemaParameters(jsonSchema: string): SchemaParameter[] { + try { + if (!jsonSchema) return [] + const parsed = JSON.parse(jsonSchema) + const properties = parsed?.function?.parameters?.properties + if (!properties) return [] + + const required = new Set(parsed?.function?.parameters?.required ?? []) + return Object.keys(properties).map((key) => ({ + name: key, + type: properties[key].type || 'any', + description: properties[key].description || '', + required: required.has(key), + })) + } catch { + return [] + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text.tsx new file mode 100644 index 00000000000..6f8bdddffad --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' + +/** + * Inline error text for a custom-tool editor section header. Lives in the + * header rather than under the editor because a tall editor plus a message + * below it shifts everything after it as the message appears while typing. + */ +export function FieldErrorText({ children }: { children: ReactNode }) { + return {children} +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx new file mode 100644 index 00000000000..a0d07c1e511 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useRef, useState } from 'react' +import { Chip, ChipInput } from '@sim/emcn' +import { ArrowUp } from 'lucide-react' + +interface GeneratePromptControlProps { + isLoading: boolean + isStreaming: boolean + onSubmit: (prompt: string) => void +} + +/** + * The "Generate" affordance above a custom-tool editor: a chip that swaps into + * an inline prompt field, then hands the trimmed prompt to the caller's wand + * stream. Owns only its own transient input state so both the schema and code + * fields can reuse it. + */ +export function GeneratePromptControl({ + isLoading, + isStreaming, + onSubmit, +}: GeneratePromptControlProps) { + const [isActive, setIsActive] = useState(false) + const [prompt, setPrompt] = useState('') + const inputRef = useRef(null) + + const activate = () => { + if (isLoading || isStreaming) return + setIsActive(true) + setPrompt('') + requestAnimationFrame(() => inputRef.current?.focus()) + } + + const submit = () => { + const trimmed = prompt.trim() + if (!trimmed || isLoading || isStreaming) return + onSubmit(trimmed) + setPrompt('') + setIsActive(false) + } + + if (!isActive) { + return ( + + Generate + + ) + } + + return ( +
+ setPrompt(e.target.value)} + onBlur={() => { + if (!prompt.trim() && !isStreaming) setIsActive(false) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submit() + } else if (e.key === 'Escape') { + e.preventDefault() + setPrompt('') + setIsActive(false) + } + }} + disabled={isStreaming} + className='w-[220px]' + placeholder='Describe what to generate...' + /> + { + e.preventDefault() + e.stopPropagation() + }} + onClick={(e) => { + e.stopPropagation() + submit() + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/index.ts b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/index.ts new file mode 100644 index 00000000000..8ab25cc5e03 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/index.ts @@ -0,0 +1,15 @@ +export { CustomToolCodeField } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field' +export { + CUSTOM_TOOL_DELETE_CONFIRM_TEXT, + extractSchemaIdentity, + extractSchemaParameters, + FUNCTION_NAME_LOCKED, + validateCustomToolSchema, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +export { CustomToolSchemaField } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field' +export { FieldErrorText } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text' +export { GeneratePromptControl } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control' +export { + useCodeGeneration, + useSchemaGeneration, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation' diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation.ts b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation.ts new file mode 100644 index 00000000000..6bb9427943b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation.ts @@ -0,0 +1,193 @@ +'use client' + +import { useMemo } from 'react' +import type { SchemaParameter } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' + +const SCHEMA_PROMPT = `You are an expert programmer specializing in creating OpenAI function calling format JSON schemas for custom tools. +Generate ONLY the JSON schema based on the user's request. +The output MUST be a single, valid JSON object, starting with { and ending with }. +The JSON schema MUST follow this specific format: +1. Top-level property "type" must be set to "function" +2. A "function" object containing: + - "name": A concise, camelCase name for the function + - "description": A clear description of what the function does + - "parameters": A JSON Schema object describing the function's parameters with: + - "type": "object" + - "properties": An object containing parameter definitions + - "required": An array of required parameter names + +Current schema: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON object. + +Valid Schema Examples: + +Example 1: +{ + "type": "function", + "function": { + "name": "getWeather", + "description": "Fetches the current weather for a specific location.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g., San Francisco, CA" + }, + "unit": { + "type": "string", + "description": "Temperature unit", + "enum": ["celsius", "fahrenheit"] + } + }, + "required": ["location"], + "additionalProperties": false + } + } +} + +Example 2: +{ + "type": "function", + "function": { + "name": "addItemToOrder", + "description": "Add one quantity of a food item to the order.", + "parameters": { + "type": "object", + "properties": { + "itemName": { + "type": "string", + "description": "The name of the food item to add to order" + }, + "quantity": { + "type": "integer", + "description": "The quantity of the item to add", + "default": 1 + } + }, + "required": ["itemName"], + "additionalProperties": false + } + } +}` + +function buildCodePrompt(schemaContext: string): string { + return `You are an expert JavaScript programmer. +Generate ONLY the raw body of a JavaScript function based on the user's request. +The code should be executable within an 'async function(params, environmentVariables) {...}' context. +- 'params' (object): Contains input parameters derived from the JSON schema. Reference these directly by name (e.g., 'userId', 'cityName'). Do NOT use 'params.paramName'. +- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env. + +${schemaContext} + +Current code: {context} + +IMPORTANT FORMATTING RULES: +1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'const apiKey = {{SERVICE_API_KEY}};' not 'const apiKey = "{{SERVICE_API_KEY}}";'). Our system replaces these placeholders before execution. +2. Reference Input Parameters/Workflow Variables: Reference them directly by name (e.g., 'const city = cityName;' or use directly in template strings like \`\${cityName}\`). Do NOT wrap in quotes or angle brackets. +3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}'). +4. Imports: Do NOT include import/require statements unless they are standard Node.js built-in modules (e.g., 'crypto', 'fs'). External libraries are not supported in this context. +5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. +6. Clarity: Write clean, readable code. +7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body. + +Example Scenario: +User Prompt: "Fetch weather data from OpenWeather API. Use the city name passed in as 'cityName' and an API Key stored as the 'OPENWEATHER_API_KEY' environment variable." + +Generated Code: +const apiKey = {{OPENWEATHER_API_KEY}}; +const url = \`https://api.openweathermap.org/data/2.5/weather?q=\${cityName}&appid=\${apiKey}\`; + +try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(\`API request failed with status \${response.status}: \${await response.text()}\`); + } + + const weatherData = await response.json(); + return weatherData; +} catch (error) { + console.error(\`Error fetching weather data: \${error.message}\`); + throw error; +}` +} + +interface UseSchemaGenerationParams { + jsonSchema: string + setJsonSchema: (updater: (prev: string) => string) => void + replaceJsonSchema: (value: string) => void +} + +/** Wand-driven generation for a custom tool's JSON schema. */ +export function useSchemaGeneration({ + jsonSchema, + setJsonSchema, + replaceJsonSchema, +}: UseSchemaGenerationParams) { + return useWand({ + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: SCHEMA_PROMPT, + placeholder: 'Describe the function parameters and structure...', + generationType: 'custom-tool-schema', + }, + currentValue: jsonSchema, + onStreamStart: () => replaceJsonSchema(''), + onGeneratedContent: (content) => replaceJsonSchema(content), + onStreamChunk: (chunk) => setJsonSchema((prev) => prev + chunk), + }) +} + +interface UseCodeGenerationParams { + functionCode: string + schemaParameters: SchemaParameter[] + setFunctionCode: (updater: (prev: string) => string) => void + replaceFunctionCode: (value: string) => void +} + +/** Wand-driven generation for a custom tool's function body, aware of the schema's parameters. */ +export function useCodeGeneration({ + functionCode, + schemaParameters, + setFunctionCode, + replaceFunctionCode, +}: UseCodeGenerationParams) { + const prompt = useMemo(() => { + if (schemaParameters.length === 0) { + return buildCodePrompt( + 'Schema parameters: (none defined yet — the user has not added any parameters to the schema)' + ) + } + const lines = schemaParameters.map((p) => { + const requiredLabel = p.required ? 'required' : 'optional' + const description = p.description ? `: ${p.description}` : '' + return `- ${p.name} (${p.type}, ${requiredLabel})${description}` + }) + return buildCodePrompt( + `Schema parameters (reference these directly by name in the generated code):\n${lines.join('\n')}` + ) + }, [schemaParameters]) + + return useWand({ + wandConfig: { + enabled: true, + maintainHistory: true, + prompt, + placeholder: 'Describe the JavaScript function to generate...', + generationType: 'javascript-function-body', + }, + currentValue: functionCode, + onStreamStart: () => replaceFunctionCode(''), + onGeneratedContent: (content) => replaceFunctionCode(content), + onStreamChunk: (chunk) => setFunctionCode((prev) => prev + chunk), + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 4d5ef5a38d8..02855214e23 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -36,4 +36,5 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { ResourceTile } from './resource-tile' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index b2b4e22d1c6..704187fe329 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -234,7 +234,7 @@ export const MessageActions = memo(function MessageActions({ disabled={forkChat.isPending} className={cn(BUTTON_CLASS, forkChat.isPending && 'cursor-not-allowed opacity-50')} > - + Branch in new chat diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts new file mode 100644 index 00000000000..507fe07a2f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts @@ -0,0 +1 @@ +export { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx new file mode 100644 index 00000000000..90907001430 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx @@ -0,0 +1,20 @@ +import type { ComponentType } from 'react' + +interface ResourceTileProps { + icon: ComponentType<{ className?: string }> +} + +/** + * Square glyph tile identifying a workspace resource — the leading visual on a + * resource's row and on its detail heading. Single source for that chrome so + * the skills and custom tools surfaces cannot drift apart. + */ +export function ResourceTile({ icon: Icon }: ResourceTileProps) { + return ( +
+
+ +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx index 7839dac4554..7ac1095a2ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx @@ -1,4 +1,5 @@ import { AgentSkillsIcon } from '@/components/icons' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile' /** * Square tile bearing the agent-skills glyph. Shared chrome for any surface @@ -6,11 +7,5 @@ import { AgentSkillsIcon } from '@/components/icons' * do not drift. */ export function SkillTile() { - return ( -
-
- -
-
- ) + return } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx index b851c2a86fa..4f261851c4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx @@ -36,8 +36,8 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) { const router = useRouter() const params = useParams() const { kind, id, label } = node.attrs as MentionAttrs - const Icon = mentionIcon(kind, id) as StyleableIcon - const iconStyle = getBareIconStyle(Icon) + const Icon = mentionIcon(kind, id, label) as StyleableIcon | undefined + const iconStyle = Icon ? getBareIconStyle(Icon) : undefined const navigable = editor.storage.mention?.navigable === true const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null @@ -55,7 +55,7 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) { onClick={path ? handleClick : undefined} title={label} > - + {Icon && } {label} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts index de6f15e6136..1b8960913ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts @@ -1,17 +1,29 @@ /** @vitest-environment node */ -import { Box, File } from 'lucide-react' +import { Workflow } from '@sim/emcn/icons' import { describe, expect, it } from 'vitest' +import { AgentSkillsIcon } from '@/components/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' import { mentionIcon } from './mention-icon' import type { MentionKind } from './types' describe('mentionIcon', () => { - it('returns the category icon for a known kind', () => { - expect(mentionIcon('file', 'x')).toBe(File) + it('uses the product-wide glyph for a known kind', () => { + expect(mentionIcon('workflow', 'x')).toBe(Workflow) }) - it('falls back to a generic icon for an empty or unrecognized kind (never undefined)', () => { + it('uses the shared skills glyph, not a one-off icon', () => { + // The same glyph SkillTile and the chat context registry render. + expect(mentionIcon('skill', 'x')).toBe(AgentSkillsIcon) + }) + + it('derives the file icon from the filename extension', () => { + expect(mentionIcon('file', 'x', 'report.pdf')).toBe(getDocumentIcon('', 'report.pdf')) + expect(mentionIcon('file', 'x', 'data.csv')).toBe(getDocumentIcon('', 'data.csv')) + }) + + it('returns undefined for an unrecognized kind so callers render no icon', () => { // The schema default is '' and a sim: link could carry a future kind — neither may crash render. - expect(mentionIcon('' as unknown as MentionKind, 'x')).toBe(Box) - expect(mentionIcon('dataset' as unknown as MentionKind, 'x')).toBe(Box) + expect(mentionIcon('' as unknown as MentionKind, 'x')).toBeUndefined() + expect(mentionIcon('dataset' as unknown as MentionKind, 'x')).toBeUndefined() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts index c655ed0f74d..828328efe6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts @@ -1,29 +1,43 @@ import type { ComponentType } from 'react' -import { Box, Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' +import { Database, Folder, Table, Workflow } from '@sim/emcn/icons' +import { AgentSkillsIcon } from '@/components/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' import { getBlock } from '@/blocks/registry' import type { MentionKind } from './types' -/** Icon component shape both the lucide kind-icons and the brand block icons satisfy. */ +/** Icon component shape both the kind icons and the brand block icons satisfy. */ export type MentionIcon = ComponentType<{ className?: string }> -const KIND_ICONS: Record, MentionIcon> = { - file: File, +/** + * The glyph each mention kind uses elsewhere in the product, so a mention reads + * as the resource it links to. Mirrors `CHAT_CONTEXT_KIND_REGISTRY`, the same + * mapping Chat's `@` menu renders. + */ +const KIND_ICONS: Record, MentionIcon> = { folder: Folder, table: Table, knowledge: Database, workflow: Workflow, - skill: Sparkles, + skill: AgentSkillsIcon, } /** - * Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by - * blockType, which is the mention `id`), falling back to a generic icon if the block was since removed; - * every other kind uses a lucide category icon, falling back to the same generic icon for an empty or - * unrecognized kind (the schema default is `''`, and a `sim:` link could carry a kind a future version - * adds) — so the result is always a real component and the chip is never icon-less. Shared by the menu - * rows and the inserted chip so both render the same icon. + * Resolves the icon for a mention: + * + * - `integration` uses the block's brand icon from the registry, keyed by the + * mention `id` (the blockType). + * - `file` uses the extension-derived document icon, so a `.pdf` and a `.csv` + * look different — matching the file list and Chat's context chips. + * - every other kind uses its product-wide glyph. + * + * Returns `undefined` when nothing sensible applies — an unrecognized kind (the + * node schema defaults `kind` to `''`, and a hand-written `sim:` link can carry + * anything) or a block that has since been removed. Callers render no icon in + * that case rather than a meaningless placeholder, which is what the chat + * context registry does too. */ -export function mentionIcon(kind: MentionKind, id: string): MentionIcon { - if (kind === 'integration') return (getBlock(id)?.icon as MentionIcon | undefined) ?? Box - return KIND_ICONS[kind] ?? Box +export function mentionIcon(kind: MentionKind, id: string, label = ''): MentionIcon | undefined { + if (kind === 'integration') return getBlock(id)?.icon as MentionIcon | undefined + if (kind === 'file') return getDocumentIcon('', label) + return KIND_ICONS[kind as Exclude] } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index 023faacc43c..1451fffe7c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -55,7 +55,7 @@ export function useMarkdownMentions( id: file.id, label: file.name, group: 'Files', - icon: mentionIcon('file', file.id), + icon: mentionIcon('file', file.id, file.name), }) for (const folder of folders.data ?? []) items.push({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 5c698d25835..cfb5e9a2ec3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -438,12 +438,16 @@ } /* - * Field variant (modal embed): match the surrounding chip fields' typography exactly — + * Field variant (form embed): match the surrounding chip fields' typography exactly — * body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the * lighter document `--text-subtle`), so the editor reads as one of the form's fields. + * The weight drops to a normal 400 as well: the document scale's 430 reads visibly + * thicker than a `ChipInput`/`ChipTextarea` beside it. Headings, `strong`, and `th` + * set their own 600, so only body text and the placeholder are affected. */ .rich-markdown-field-prose { font-size: 14px; + font-weight: 400; line-height: 22px; } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 8936c9a489b..e0723b4d5ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ChipTextarea, chipFieldSurfaceClass, cn } from '@sim/emcn' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' @@ -30,9 +30,14 @@ interface RichMarkdownFieldProps { /** True while `value` is being pushed in externally (AI generation) — the editor turns read-only and mirrors each update. */ isStreaming?: boolean autoFocus?: boolean - /** Min height of the scroll box in px. */ + /** Min height of the editor box in px. */ minHeight?: number - /** Max height of the scroll box in px before it scrolls. */ + /** + * Max height in px before the box scrolls internally. Set this inside a modal, + * where the surface itself cannot grow. Omit on a full-page surface so the + * editor grows with its content and the page owns the only scrollbar — a + * capped box stranded above empty page is worse than a long document. + */ maxHeight?: number /** Swaps the border to the error token (the message itself is rendered by the surrounding field). */ error?: boolean @@ -61,7 +66,7 @@ function LoadedRichMarkdownField({ isStreaming = false, autoFocus = false, minHeight = 140, - maxHeight = 360, + maxHeight, error = false, workspaceId, disableTagging, @@ -176,10 +181,17 @@ function LoadedRichMarkdownField({
@@ -206,12 +218,44 @@ function RawMarkdownField({ disabled = false, isStreaming = false, minHeight = 140, - maxHeight = 360, + maxHeight, error = false, onPasteText, }: RichMarkdownFieldProps) { + // Disabled-look without the `disabled` attribute — a disabled textarea is + // inert to wheel/scrollbar, but locked content must stay scrollable. + const lockedView = disabled && !isStreaming + + /** + * Uncapped, the textarea grows with its content so it matches the WYSIWYG + * path on a full-page surface — a textarea has no intrinsic auto-height, so + * the height is synced to `scrollHeight` on every value change. Capped (in a + * modal) it keeps its own scrollbar and this is skipped. + */ + const textareaRef = useRef(null) + const autoGrow = maxHeight === undefined + useLayoutEffect(() => { + if (!autoGrow) return + const el = textareaRef.current + if (!el) return + + const measure = () => { + el.style.height = 'auto' + el.style.height = `${Math.max(el.scrollHeight, minHeight)}px` + } + measure() + + // The box is `overflow-hidden` while uncapped, so a width change that + // re-wraps lines without touching `value` would clip the tail with no + // scrollbar to reach it — re-measure whenever the element resizes. + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [autoGrow, value, minHeight]) + return ( onChange(event.target.value)} onPaste={(event) => { @@ -220,15 +264,20 @@ function RawMarkdownField({ }} placeholder={placeholder} error={error} - readOnly={disabled || isStreaming} + viewOnly={lockedView} + readOnly={isStreaming} + tabIndex={lockedView ? -1 : undefined} + className={cn(lockedView && 'select-none opacity-50', autoGrow && 'overflow-hidden')} style={{ minHeight, maxHeight }} /> ) } /** - * A controlled, string-valued markdown editor for modal fields. Drop it inside a `ChipModalField - * type='custom'`. Mirrors the file editor's safety gate (decided once from the initial value): + * A controlled, string-valued markdown editor. Inside a modal, drop it in a `ChipModalField + * type='custom'` and pass a `maxHeight` so it scrolls within the modal; on a full-page surface omit + * `maxHeight` so it grows with its content and the page owns the only scrollbar. + * Mirrors the file editor's safety gate (decided once from the initial value): * round-trip-safe content opens in the WYSIWYG editor, while lossy markdown (raw HTML, footnotes, * comments) falls back to raw-text editing so an edit can't silently drop those constructs. */ diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx index 734d4a14c85..ea4fa847a2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx @@ -6,6 +6,7 @@ import { Check, Plus } from 'lucide-react' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { isSkillNameConflictError } from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SuggestedSkill } from '@/blocks/types' import { useCreateSkill, useSkills } from '@/hooks/queries/skills' @@ -77,8 +78,15 @@ export function IntegrationSkillsSection({ position, skill_count: skills.length, }) - } catch { - toast.error(`Failed to add "${skill.name}" — please try again`) + } catch (error) { + // A name conflict just means the skill is already in this workspace — + // everyone with workspace access can already see and use it, so there is + // nothing to request and retrying can never succeed. + if (isSkillNameConflictError(error)) { + toast.error(`"${skill.name}" is already in this workspace`) + } else { + toast.error(`Failed to add "${skill.name}" — please try again`) + } } finally { inFlightRef.current.delete(skill.name) setPendingNames((prev) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index 85b4a56b913..94aae591694 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -96,6 +96,22 @@ export const customBlockIdUrlKeys = { clearOnDefault: true, } as const +/** + * `custom-tool-id` deep-links the Custom Tools settings tab to a specific + * tool's detail sub-view. The "create new" flow stays in local state — only + * existing entities are deep-linkable. + */ +export const customToolIdParam = { + key: 'custom-tool-id', + parser: parseAsString, +} as const + +/** Opening a tool's detail is a destination → push to history; clear on close. */ +export const customToolIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + /** * `fork-direction` is the sync direction (push/pull) on the parent fork's detail * page — shareable view state, so a copied link opens the same side of the sync. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index cf2bf5caf18..5d2a80a52e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -81,6 +81,9 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) +const DomainSettings = dynamic(() => + import('@/ee/sso/components/domain-settings').then((m) => m.DomainSettings) +) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (m) => m.SessionPolicySettings @@ -163,6 +166,9 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'sso' && organizationId && } + {effectiveSection === 'domains' && organizationId && ( + + )} {effectiveSection === 'sessions' && organizationId && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx new file mode 100644 index 00000000000..db3b2f47db2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx @@ -0,0 +1,336 @@ +'use client' + +import { useMemo, useState } from 'react' +import { ChipConfirmModal, toast } from '@sim/emcn' +import { ArrowLeft, Wrench } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components' +import { + CredentialDetailHeading, + UnsavedChangesModal, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + CUSTOM_TOOL_DELETE_CONFIRM_TEXT, + CustomToolCodeField, + CustomToolSchemaField, + extractSchemaIdentity, + extractSchemaParameters, + FieldErrorText, + FUNCTION_NAME_LOCKED, + GeneratePromptControl, + useCodeGeneration, + useSchemaGeneration, + validateCustomToolSchema, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' +import { + useCreateCustomTool, + useDeleteCustomTool, + useUpdateCustomTool, +} from '@/hooks/queries/custom-tools' + +const logger = createLogger('CustomToolDetail') + +interface CustomToolDetailProps { + workspaceId: string + /** `null` on the create flow, which starts from an empty draft. */ + tool: CustomToolDefinition | null + /** Viewers without edit rights get the same page with every control inert. */ + readOnly?: boolean + onBack: () => void + /** Lands the caller on the tool it just created, matching the skill create flow. */ + onCreated?: (toolId: string) => void +} + +/** + * Full-page custom tool editor rendered as a settings detail sub-view: a back + * chip, dirty-gated Discard/Save, Delete, and the Schema and Code editors + * stacked (no tabs — the page has room for both). Uses the same fields as the + * canvas modal so the two surfaces never drift. + */ +export function CustomToolDetail({ + workspaceId, + tool, + readOnly = false, + onBack, + onCreated, +}: CustomToolDetailProps) { + const isEditing = !!tool + + const createTool = useCreateCustomTool() + const updateTool = useUpdateCustomTool() + const deleteTool = useDeleteCustomTool() + + /** + * The dirty baseline. Seeded once at mount — the list keys this view by tool + * id, so picking a different tool remounts it — and moved only by an explicit + * save. A background list refetch must never shift it out from under + * in-progress edits. + */ + const [seededSchema, setSeededSchema] = useState(() => + tool ? JSON.stringify(tool.schema, null, 2) : '' + ) + const [seededCode, setSeededCode] = useState(() => tool?.code ?? '') + + const [jsonSchema, setJsonSchema] = useState(seededSchema) + const [functionCode, setFunctionCode] = useState(seededCode) + const [schemaError, setSchemaError] = useState(null) + const [codeError, setCodeError] = useState(null) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const schemaParameters = useMemo(() => extractSchemaParameters(jsonSchema), [jsonSchema]) + + /** + * Heading reflects the draft schema, so the tool names itself as you type + * rather than hiding its identity inside the JSON. Falls back to the saved + * tool while the draft is mid-edit and unparseable. + */ + const identity = useMemo(() => extractSchemaIdentity(jsonSchema), [jsonSchema]) + + const schemaGeneration = useSchemaGeneration({ + jsonSchema, + setJsonSchema: (updater) => { + setJsonSchema(updater) + setSchemaError(null) + }, + replaceJsonSchema: (value) => { + setJsonSchema(value) + setSchemaError(null) + }, + }) + + const codeGeneration = useCodeGeneration({ + functionCode, + schemaParameters, + setFunctionCode: (updater) => { + setFunctionCode(updater) + setCodeError(null) + }, + replaceFunctionCode: (value) => { + setFunctionCode(value) + setCodeError(null) + }, + }) + + const dirty = isEditing + ? jsonSchema !== seededSchema || functionCode !== seededCode + : jsonSchema.trim().length > 0 || functionCode.trim().length > 0 + + const guard = useSettingsUnsavedGuard({ isDirty: dirty }) + + const saving = createTool.isPending || updateTool.isPending + const isSchemaValid = useMemo(() => validateCustomToolSchema(jsonSchema).isValid, [jsonSchema]) + const streaming = schemaGeneration.isStreaming || codeGeneration.isStreaming + + const handleDiscard = () => { + setJsonSchema(seededSchema) + setFunctionCode(seededCode) + setSchemaError(null) + setCodeError(null) + } + + const handleSave = async () => { + if (saving) return + + if (!jsonSchema.trim()) { + setSchemaError('Schema cannot be empty') + return + } + + const { isValid, error } = validateCustomToolSchema(jsonSchema) + if (!isValid) { + setSchemaError(error) + return + } + + setSchemaError(null) + setCodeError(null) + + const schema = JSON.parse(jsonSchema) + const title = schema.function.name + + try { + if (tool) { + await updateTool.mutateAsync({ + workspaceId, + toolId: tool.id, + updates: { title, schema, code: functionCode }, + }) + // Saving an edit keeps you on the tool (matching the other settings + // detail views); re-baseline so Discard/Save drop back out of the header. + setSeededSchema(jsonSchema) + setSeededCode(functionCode) + } else { + const created = await createTool.mutateAsync({ + workspaceId, + tool: { title, schema, code: functionCode }, + }) + // The upsert responds with the workspace's whole tool list (newest + // first), not just the new row — match by title rather than index. + const createdId = created.find((t) => t.title === title)?.id + if (createdId) onCreated?.(createdId) + else onBack() + } + } catch (error) { + logger.error('Failed to save custom tool', error) + const message = getErrorMessage(error, 'Failed to save custom tool') + setSchemaError( + message.includes('Cannot change function name') ? FUNCTION_NAME_LOCKED : message + ) + } + } + + const handleConfirmDelete = async () => { + if (!tool) return + setShowDeleteConfirm(false) + try { + await deleteTool.mutateAsync({ workspaceId, toolId: tool.id }) + onBack() + } catch (error) { + logger.error('Failed to delete custom tool', error) + toast.error("Couldn't delete tool", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + } + + /** + * On create, the primary action is always visible so the page announces what + * it is for — disabled until the schema is a valid function definition. + * (`saveDiscardActions` is dirty-gated and would render nothing on an empty + * draft.) Discard still only appears once there is something to discard. + */ + const createToolActions: SettingsAction[] = [ + ...(dirty ? [{ text: 'Discard', onSelect: handleDiscard, disabled: saving }] : []), + { + text: saving ? 'Creating...' : 'Create', + variant: 'primary' as const, + onSelect: handleSave, + disabled: saving || streaming || !isSchemaValid, + }, + ] + + return ( + <> + guard.guardBack(onBack) }} + title={identity.name || tool?.title || 'New tool'} + actions={[ + ...(readOnly + ? [] + : isEditing + ? saveDiscardActions({ + dirty, + saving, + onSave: handleSave, + onDiscard: handleDiscard, + saveDisabled: !isSchemaValid || streaming, + }) + : createToolActions), + ...(tool && !readOnly + ? [ + { + text: deleteTool.isPending ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: () => setShowDeleteConfirm(true), + disabled: deleteTool.isPending, + }, + ] + : []), + ]} + > +
+ } + title={identity.name || tool?.title || 'New tool'} + subtitle={ + identity.description || + tool?.schema.function.description || + 'Define the JSON schema your agents call, and the code that runs.' + } + /> + + {schemaError} : undefined + } + action={ + readOnly ? undefined : ( + schemaGeneration.generateStream({ prompt })} + /> + ) + } + > + { + setJsonSchema(value) + setSchemaError(value.trim() ? validateCustomToolSchema(value).error : null) + }} + error={!!schemaError} + generation={schemaGeneration} + disabled={readOnly} + /> + + + {codeError} : undefined} + action={ + readOnly ? undefined : ( + codeGeneration.generateStream({ prompt })} + /> + ) + } + > + { + setFunctionCode(value) + if (codeError) setCodeError(null) + }} + error={!!codeError} + generation={codeGeneration} + schemaParameters={schemaParameters} + workspaceId={workspaceId} + disabled={readOnly} + /> + +
+
+ + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/index.ts new file mode 100644 index 00000000000..ffc8ae4ff17 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/index.ts @@ -0,0 +1 @@ +export { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 2a5e789da06..95ce91a808f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -1,22 +1,24 @@ 'use client' import { useState } from 'react' -import { ChipConfirmModal } from '@sim/emcn' -import { createLogger } from '@sim/logger' +import { Wrench } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' -import { Plus } from 'lucide-react' +import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + customToolIdParam, + customToolIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' -import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools' - -const logger = createLogger('CustomToolsSettings') +import { useCustomTools } from '@/hooks/queries/custom-tools' export function CustomTools() { const params = useParams() @@ -24,15 +26,22 @@ export function CustomTools() { const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('custom-tools', workspacePermissions) - const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId) - const deleteToolMutation = useDeleteCustomTool() + const { data: tools = [], isLoading, error } = useCustomTools(workspaceId) const [searchTerm, setSearchTerm] = useSettingsSearch() - const [deletingTools, setDeletingTools] = useState>(() => new Set()) - const [editingTool, setEditingTool] = useState(null) - const [showAddForm, setShowAddForm] = useState(false) - const [toolToDelete, setToolToDelete] = useState<{ id: string; name: string } | null>(null) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [selectedToolId, setSelectedToolId] = useQueryState(customToolIdParam.key, { + ...customToolIdParam.parser, + ...customToolIdUrlKeys, + }) + /** The create flow has no entity id and is not deep-linkable — stays local. */ + const [isCreating, setIsCreating] = useState(false) + + const selectedTool = selectedToolId ? tools.find((t) => t.id === selectedToolId) : undefined + + const closeDetail = () => { + setIsCreating(false) + void setSelectedToolId(null, { history: 'replace' }) + } const filteredTools = tools.filter((tool) => { if (!searchTerm.trim()) return true @@ -44,52 +53,7 @@ export function CustomTools() { ) }) - const handleDeleteClick = (toolId: string) => { - const tool = tools.find((t) => t.id === toolId) - if (!tool) return - - setToolToDelete({ - id: toolId, - name: tool.title || tool.schema?.function?.name || 'this custom tool', - }) - setShowDeleteDialog(true) - } - - const handleDeleteTool = async () => { - if (!toolToDelete) return - - const tool = tools.find((t) => t.id === toolToDelete.id) - if (!tool) return - - setDeletingTools((prev) => new Set(prev).add(toolToDelete.id)) - setShowDeleteDialog(false) - - try { - await deleteToolMutation.mutateAsync({ - workspaceId: tool.workspaceId ?? null, - toolId: toolToDelete.id, - }) - logger.info(`Deleted custom tool: ${toolToDelete.id}`) - } catch (error) { - logger.error('Error deleting custom tool:', error) - } finally { - setDeletingTools((prev) => { - const next = new Set(prev) - next.delete(toolToDelete.id) - return next - }) - setToolToDelete(null) - } - } - - const handleToolSaved = () => { - setShowAddForm(false) - setEditingTool(null) - refetchTools() - } - - const hasTools = tools && tools.length > 0 - const showEmptyState = !hasTools && !showAddForm && !editingTool + const showEmptyState = tools.length === 0 const showNoResults = searchTerm.trim() && filteredTools.length === 0 && tools.length > 0 const actions: SettingsAction[] = canEdit @@ -98,115 +62,81 @@ export function CustomTools() { text: 'Add tool', icon: Plus, variant: 'primary', - onSelect: () => setShowAddForm(true), + onSelect: () => setIsCreating(true), disabled: isLoading, }, ] : [] - return ( - <> - - {error ? ( -
-

- {getErrorMessage(error, 'Failed to load tools')} -

-
- ) : isLoading ? null : showEmptyState ? ( - - {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'} - - ) : ( -
- {filteredTools.map((tool) => ( -
-
- - {tool.title || 'Unnamed Tool'} - - {tool.schema?.function?.description && ( -

- {tool.schema.function.description} -

- )} -
- {canEdit && ( -
- setEditingTool(tool.id) }, - { - label: 'Delete', - destructive: true, - disabled: deletingTools.has(tool.id), - onSelect: () => handleDeleteClick(tool.id), - }, - ]} - /> -
- )} -
- ))} - {showNoResults && ( - - No tools found matching "{searchTerm}" - - )} -
- )} -
+ /** + * Hold the first paint while a deep-linked id could still resolve — the tools + * query and the workspace permissions context both gate the detail, so a valid + * link never flashes the list before jumping to it. A dead id still falls back + * to the list. + */ + if (selectedToolId !== null && (isLoading || workspacePermissions.isLoading)) return null - {canEdit && ( - { - if (!open) { - setShowAddForm(false) - setEditingTool(null) - } - }} - onSave={handleToolSaved} - onDelete={() => {}} - blockId='' - initialValues={ - editingTool - ? (() => { - const tool = tools.find((t) => t.id === editingTool) - return tool?.schema - ? { id: tool.id, schema: tool.schema, code: tool.code } - : undefined - })() - : undefined - } - /> - )} + if ((isCreating && canEdit) || selectedTool) { + return ( + { + setIsCreating(false) + void setSelectedToolId(toolId) + }} + /> + ) + } - {canEdit && ( - { - if (!open) setShowDeleteDialog(false) - }} - srTitle='Delete Custom Tool' - title='Delete Custom Tool' - text={[ - 'Are you sure you want to delete ', - { text: toolToDelete?.name ?? 'this tool', bold: true }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: handleDeleteTool }} - /> + return ( + + {error ? ( +
+

+ {getErrorMessage(error, 'Failed to load tools')} +

+
+ ) : isLoading ? null : showEmptyState ? ( + + {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'} + + ) : ( +
+ {filteredTools.map((tool) => ( + + ))} + {showNoResults && ( + + No tools found matching "{searchTerm}" + + )} +
)} - +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index 88d9365e65a..7e31036010d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -20,6 +20,11 @@ interface SettingsResourceRowProps { * normalize to 20px so a fallback icon doesn't balloon. */ iconFill?: boolean + /** + * Fills the tile like the skills/tools resource tiles instead of the default + * page-background tile, so a settings list can match its gallery counterpart. + */ + iconFilled?: boolean /** Primary line — truncates. */ title: ReactNode /** Secondary muted line — truncates. */ @@ -29,11 +34,12 @@ interface SettingsResourceRowProps { } const TILE_BASE = - 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] [&_svg]:size-5' + 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' export function SettingsResourceRow({ icon, iconFill = false, + iconFilled = false, title, description, trailing, @@ -41,7 +47,13 @@ export function SettingsResourceRow({ return (
-
+
{icon}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index fc2e914762b..2996eb3c238 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -39,6 +39,7 @@ describe('unified settings navigation', () => { { id: 'inbox', label: 'Sim mailer', section: 'system' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, + { id: 'domains', label: 'Verified domains', section: 'enterprise' }, { id: 'sessions', label: 'Session policies', section: 'enterprise' }, { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx new file mode 100644 index 00000000000..4cdb2d4a9ec --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx @@ -0,0 +1,59 @@ +'use client' + +import { + MemberRow, + SKILL_EDITOR_ROLE_OPTIONS, + type SkillEditorRole, + skillEditorLockReason, +} from '@/components/permissions' +import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail' +import type { SkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members' + +interface SkillEditorsCardProps { + editors: SkillEditorsController + /** Whether the viewer can edit the skill (and therefore manage its editors). */ + canEdit: boolean +} + +/** + * Page-styled editor roster for the skill detail page: workspace admins + * (derived, always editors) and explicitly added editors. Everyone in the + * workspace can already see and use the skill — this list gates editing only. + * Adding people happens through the header Share action. + * + * Rows are the shared {@link MemberRow} so the roster is chrome-identical to the + * credential detail surface. Skill membership is binary, so the role control + * carries a single `Editor` option and is disabled on every row; a derived + * (workspace-admin) row also locks Remove and explains the inheritance on hover. + */ +export function SkillEditorsCard({ editors, canEdit }: SkillEditorsCardProps) { + return ( + + {editors.editorsError ? ( + + Couldn't load editors. You may no longer have access to this skill. + + ) : editors.editorsLoading ? null : ( +
+ {editors.editors.map((editor) => ( + + key={editor.id} + member={{ + userId: editor.userId, + userName: editor.userName, + userEmail: editor.userEmail, + role: 'editor', + }} + roleOptions={SKILL_EDITOR_ROLE_OPTIONS} + lockReason={skillEditorLockReason(editor.isWorkspaceAdmin)} + canManage={canEdit} + roleDisabled + removeDisabled={editor.isWorkspaceAdmin} + onRemove={() => editors.removeEditor(editor.userId)} + /> + ))} +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx new file mode 100644 index 00000000000..5f22dccfcf4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import { SkillDetail } from '@/app/workspace/[workspaceId]/skills/[skillId]/skill-detail' + +export const metadata: Metadata = { + title: 'Skill', +} + +export default async function SkillDetailPage({ + params, +}: { + params: Promise<{ workspaceId: string; skillId: string }> +}) { + const { workspaceId, skillId } = await params + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx new file mode 100644 index 00000000000..c3189d4d50a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -0,0 +1,267 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipLink, Send, toast } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import { AddPeopleModal } from '@/components/permissions' +import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { + CredentialDetailHeading, + CredentialDetailLayout, + UnsavedChangesModal, + useUnsavedChangesGuard, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card' +import { + type SkillFieldErrors, + SkillFields, +} from '@/app/workspace/[workspaceId]/skills/components/skill-fields' +import { useSkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members' +import { + isSkillNameConflictError, + parseSkillMarkdown, + validateSkillName, +} from '@/app/workspace/[workspaceId]/skills/components/utils' +import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills' + +const logger = createLogger('SkillDetail') + +interface SkillDetailProps { + workspaceId: string + skillId: string +} + +/** + * Full-page skill detail, mirroring the integration credential detail surface: + * a fixed action bar (Share / Delete / Save), a heading, editable Name / + * Description / Content sections, and the Skill Editors roster. Non-editors + * and built-in template skills render read-only. + */ +export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { + const router = useRouter() + const skillsHref = `/workspace/${workspaceId}/skills` + + const { data: skills = [], isPending: skillsLoading } = useSkills(workspaceId) + const updateSkill = useUpdateSkill() + const deleteSkill = useDeleteSkill() + const skill = skills.find((s) => s.id === skillId) ?? null + const isBuiltin = !!skill?.readOnly + const editors = useSkillEditorsController({ + skillId, + workspaceId, + // Built-ins have no editors; skip the roster fetch (it would 404). + enabled: !!skill && !isBuiltin, + }) + const canEdit = !isBuiltin && !!skill?.canEdit + + const [nameDraft, setNameDraft] = useState('') + const [descriptionDraft, setDescriptionDraft] = useState('') + const [contentDraft, setContentDraft] = useState('') + /** Bumped to remount the seed-once rich Content editor on programmatic sets. */ + const [contentSeed, setContentSeed] = useState(0) + const [errors, setErrors] = useState({}) + const [shareOpen, setShareOpen] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [prevSkillId, setPrevSkillId] = useState(null) + + // Seed drafts when the skill first resolves (or the route id changes); a + // background refetch of the same skill must not clobber an in-progress edit. + if (skill && skill.id !== prevSkillId) { + setPrevSkillId(skill.id) + setNameDraft(skill.name) + setDescriptionDraft(skill.description) + setContentDraft(skill.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + } + + const isDirty = + !!skill && + !isBuiltin && + (nameDraft !== skill.name || + descriptionDraft !== skill.description || + contentDraft !== skill.content) + + const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref }) + + const handleSave = async () => { + if (!skill || !canEdit || !isDirty || updateSkill.isPending) return + + const newErrors: SkillFieldErrors = {} + const nameError = validateSkillName(nameDraft) + if (nameError) newErrors.name = nameError + if (!descriptionDraft.trim()) newErrors.description = 'Description is required' + if (!contentDraft.trim()) newErrors.content = 'Content is required' + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + return + } + + try { + // Partial update: only the fields that changed go over the wire. + await updateSkill.mutateAsync({ + workspaceId, + skillId: skill.id, + updates: { + ...(nameDraft !== skill.name ? { name: nameDraft } : {}), + ...(descriptionDraft !== skill.description ? { description: descriptionDraft } : {}), + ...(contentDraft !== skill.content ? { content: contentDraft } : {}), + }, + }) + setErrors({}) + } catch (error) { + if (isSkillNameConflictError(error)) { + setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) + } else { + toast.error("Couldn't save skill", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + logger.error('Failed to save skill', error) + } + } + + const handleConfirmDelete = async () => { + if (!skill) return + setShowDeleteConfirm(false) + try { + await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id }) + router.push(skillsHref) + } catch (error) { + logger.error('Failed to delete skill', error) + } + } + + /** + * Pasting a full SKILL.md destructures it into the fields. Gated on a real + * YAML `name:` key so a stray `---` or heading-only snippet pastes as + * ordinary content instead of silently overwriting all three fields. + */ + const handleContentPaste = (text: string): boolean => { + const parsed = parseSkillMarkdown(text) + if (!parsed.nameFromFrontmatter) return false + setNameDraft(parsed.name) + setDescriptionDraft(parsed.description) + setContentDraft(parsed.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + return true + } + + const back = ( + + Skills + + ) + + const actions = + skill && canEdit ? ( + <> + setShareOpen(true)}> + Share + + setShowDeleteConfirm(true)} disabled={deleteSkill.isPending}> + Delete + + + {updateSkill.isPending ? 'Saving...' : 'Save'} + + + ) : null + + if (skillsLoading && !skill) { + return ( + +

Loading…

+
+ ) + } + + if (!skill) { + return ( + +

Skill not found.

+
+ ) + } + + const readOnly = isBuiltin || !canEdit + const lockReason = !readOnly + ? null + : isBuiltin + ? 'Built-in skills are read-only' + : 'You need to be a skill editor to edit this skill' + + return ( + <> + + } + title={skill.name} + subtitle={isBuiltin ? 'Built-in skill' : skill.description} + /> + + { + setNameDraft(value) + if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) + }} + onDescriptionChange={(value) => { + setDescriptionDraft(value) + if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) + }} + onContentChange={(value) => { + setContentDraft(value) + if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) + }} + errors={errors} + contentKey={`${skill.id}:${contentSeed}`} + workspaceId={workspaceId} + disabled={readOnly} + lockReason={lockReason} + onPasteText={handleContentPaste} + /> + + {!isBuiltin && } + + + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts new file mode 100644 index 00000000000..6c3ae2f63fb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts @@ -0,0 +1,14 @@ +/** + * Field copy shared by every skill editing surface. The two pages share JSX via + * `SkillFields`, but the canvas modal must frame its fields with + * `ChipModalField` — required inside a `ChipModalBody` — so it cannot. These + * strings are what keep the modal in step with the pages. + */ + +export const SKILL_NAME_PLACEHOLDER = 'my-skill-name' +export const SKILL_NAME_HINT = 'Lowercase letters, numbers, and hyphens (e.g. my-skill)' +export const SKILL_DESCRIPTION_PLACEHOLDER = 'What this skill does and when to use it...' +export const SKILL_CONTENT_PLACEHOLDER = 'Skill instructions in markdown...' + +/** Mirrors `skillDescriptionSchema` in the contract. */ +export const SKILL_DESCRIPTION_MAX_LENGTH = 1024 diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts new file mode 100644 index 00000000000..257dcb31ce0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts @@ -0,0 +1,4 @@ +export { + type SkillFieldErrors, + SkillFields, +} from '@/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields' diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx new file mode 100644 index 00000000000..c865528ad2a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx @@ -0,0 +1,166 @@ +'use client' + +import type { ReactNode } from 'react' +import { ChipInput, ChipTextarea, chipFieldSurfaceClass, cn, Tooltip } from '@sim/emcn' +import dynamic from 'next/dynamic' +import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + SKILL_CONTENT_PLACEHOLDER, + SKILL_DESCRIPTION_MAX_LENGTH, + SKILL_DESCRIPTION_PLACEHOLDER, + SKILL_NAME_HINT, + SKILL_NAME_PLACEHOLDER, +} from '@/app/workspace/[workspaceId]/skills/components/skill-copy' + +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + +export interface SkillFieldErrors { + name?: string + description?: string + content?: string +} + +interface SkillFieldsProps { + name: string + description: string + content: string + onNameChange: (value: string) => void + onDescriptionChange: (value: string) => void + onContentChange: (value: string) => void + errors: SkillFieldErrors + /** Remounts the seed-once rich editor when content is set programmatically. */ + contentKey: string | number + workspaceId: string + disabled?: boolean + /** + * Why the fields are locked (built-in skill, or viewer is not an editor). + * Renders a tooltip explaining it — a disabled control swallows hover, so each + * field is wrapped rather than the control itself. + */ + lockReason?: string | null + /** Intercepts a full SKILL.md paste into Content. */ + onPasteText?: (text: string) => boolean +} + +interface FieldLockTooltipProps { + reason: string | null | undefined + children: ReactNode +} + +function FieldLockTooltip({ reason, children }: FieldLockTooltipProps) { + if (!reason) return <>{children} + return ( + + +
{children}
+
+ {reason} +
+ ) +} + +/** + * The hint-or-error line under a field. Renders nothing when there is neither, + * so a field without a message reserves no space. + */ +function FieldMessage({ error, hint }: { error?: string; hint?: string }) { + const message = error ?? hint + if (!message) return null + return ( +

+ {message} +

+ ) +} + +/** + * The Name / Description / Content trio as the full-page skill surfaces render + * it — `DetailSection` rows with the error line beneath each field. Shared by + * the create and detail pages so the copy, sizing, and error treatment cannot + * drift between them. The canvas modal frames the same fields with + * `ChipModalField` (required inside a `ChipModalBody`) and shares the copy + * constants instead. + */ +export function SkillFields({ + name, + description, + content, + onNameChange, + onDescriptionChange, + onContentChange, + errors, + contentKey, + workspaceId, + disabled = false, + lockReason, + onPasteText, +}: SkillFieldsProps) { + return ( + <> + + + onNameChange(event.target.value)} + placeholder={SKILL_NAME_PLACEHOLDER} + autoComplete='off' + data-lpignore='true' + disabled={disabled} + error={!!errors.name} + /> + + + + + + + onDescriptionChange(event.target.value)} + placeholder={SKILL_DESCRIPTION_PLACEHOLDER} + maxLength={SKILL_DESCRIPTION_MAX_LENGTH} + autoComplete='off' + data-lpignore='true' + disabled={disabled} + error={!!errors.description} + /> + + + + + + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts index a3408dbd9eb..425a026a858 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts @@ -1 +1,2 @@ export { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import/skill-import' +export { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button' diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx new file mode 100644 index 00000000000..02d7e616262 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useRef, useState } from 'react' +import { Chip, Loader, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { + type ParsedSkill, + readSkillFile, + SKILL_IMPORT_ACCEPT, +} from '@/app/workspace/[workspaceId]/skills/components/utils' + +interface SkillImportButtonProps { + onImport: (data: ParsedSkill) => void + disabled?: boolean +} + +/** + * Header action that imports an existing SKILL.md into the create form. Opens + * the OS file picker directly — no field on the page — and reports failures as + * a toast, since the action bar has no room for an inline error. + */ +export function SkillImportButton({ onImport, disabled = false }: SkillImportButtonProps) { + const inputRef = useRef(null) + const [importing, setImporting] = useState(false) + + const handleFile = async (file: File) => { + setImporting(true) + try { + onImport(await readSkillFile(file)) + } catch (error) { + toast.error("Couldn't import skill", { + description: getErrorMessage(error, 'Please try a .md or .zip file.'), + }) + } finally { + setImporting(false) + } + } + + return ( + <> + inputRef.current?.click()} disabled={disabled || importing}> + {importing ? : 'Import'} + + { + const file = event.target.files?.[0] + event.target.value = '' + if (file) void handleFile(file) + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx index a49e5c2280a..4a06071c85b 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx @@ -1,156 +1,52 @@ 'use client' -import { useCallback, useState } from 'react' -import { Chip, ChipInput, ChipModalField, Loader } from '@sim/emcn' +import { useState } from 'react' +import { ChipModalField } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { requestJson } from '@/lib/api/client/request' -import { importSkillContract } from '@/lib/api/contracts' import { - extractSkillFromZip, - parseSkillMarkdown, + type ParsedSkill, + readSkillFile, + SKILL_IMPORT_ACCEPT, } from '@/app/workspace/[workspaceId]/skills/components/utils' -interface ImportedSkill { - name: string - description: string - content: string -} - interface SkillImportProps { - onImport: (data: ImportedSkill) => void -} - -type ImportState = 'idle' | 'loading' | 'error' - -const ACCEPTED_EXTENSIONS = ['.md', '.zip'] - -function isAcceptedFile(file: File): boolean { - const name = file.name.toLowerCase() - return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext)) + onImport: (data: ParsedSkill) => void } +/** + * The canvas modal's Import tab: a single file field that reads a SKILL.md (or + * a ZIP containing one) into the create form. The full-page create surface uses + * {@link SkillImportButton} in its action bar instead of a field. + */ export function SkillImport({ onImport }: SkillImportProps) { - const [githubUrl, setGithubUrl] = useState('') - const [githubState, setGithubState] = useState('idle') - const [githubError, setGithubError] = useState('') + const [importing, setImporting] = useState(false) + const [error, setError] = useState('') - const [fileState, setFileState] = useState('idle') - const [fileError, setFileError] = useState('') - - const handleGithubImport = useCallback(async () => { - const trimmed = githubUrl.trim() - if (!trimmed) { - setGithubError('Please enter a GitHub URL') - setGithubState('error') - return - } - - setGithubState('loading') - setGithubError('') + const handleFiles = async (files: File[]) => { + const file = files[0] + if (!file) return + setImporting(true) + setError('') try { - const data = await requestJson(importSkillContract, { body: { url: trimmed } }) - const parsed = parseSkillMarkdown(data.content) - setGithubState('idle') - onImport(parsed) + onImport(await readSkillFile(file)) } catch (err) { - const message = getErrorMessage(err, 'Failed to import from GitHub') - setGithubError(message) - setGithubState('error') + setError(getErrorMessage(err, 'Failed to process file')) + } finally { + setImporting(false) } - }, [githubUrl, onImport]) - - const processFile = useCallback( - async (file: File) => { - if (!isAcceptedFile(file)) { - setFileError('Unsupported file type. Use .md or .zip files.') - setFileState('error') - return - } - - setFileState('loading') - setFileError('') - - try { - let rawContent: string - - if (file.name.toLowerCase().endsWith('.zip')) { - if (file.size > 5 * 1024 * 1024) { - setFileError('ZIP file is too large (max 5 MB)') - setFileState('error') - return - } - rawContent = await extractSkillFromZip(file) - } else { - rawContent = await file.text() - } - - const parsed = parseSkillMarkdown(rawContent) - setFileState('idle') - onImport(parsed) - } catch (err) { - const message = getErrorMessage(err, 'Failed to process file') - setFileError(message) - setFileState('error') - } - }, - [onImport] - ) - - const handleFiles = useCallback( - (files: File[]) => { - const file = files[0] - if (file) processFile(file) - }, - [processFile] - ) - - return ( -
- -
- { - setGithubUrl(e.target.value) - if (githubError) setGithubError('') - }} - disabled={githubState === 'loading'} - className='min-w-0 flex-1' - /> - - {githubState === 'loading' ? : 'Fetch'} - -
-
- - - - -
- ) -} + } -function ImportDivider() { return ( -
-
- or -
-
+ void handleFiles(files)} + loading={importing} + label={importing ? 'Importing…' : undefined} + description='.md file with YAML frontmatter, or .zip containing a SKILL.md' + error={error || undefined} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts new file mode 100644 index 00000000000..18d6f7716c6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts @@ -0,0 +1,4 @@ +export { + type SkillEditorsController, + useSkillEditorsController, +} from './use-skill-editors-controller' diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts new file mode 100644 index 00000000000..bcc2e6483fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts @@ -0,0 +1,76 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { createLogger } from '@sim/logger' +import type { AddPeopleTarget } from '@/components/permissions' +import type { SkillEditor } from '@/lib/api/contracts' +import { useRemoveSkillMember, useSkillMembers, useUpsertSkillMember } from '@/hooks/queries/skills' + +const logger = createLogger('SkillEditorsController') + +export interface SkillEditorsController { + editors: SkillEditor[] + editorsLoading: boolean + editorsError: boolean + /** Lowercased emails already on the roster (incl. workspace admins) — feeds the Add People modal. */ + existingEditorEmails: Set + addEditor: (target: AddPeopleTarget) => Promise + removeEditor: (userId: string) => Promise +} + +interface UseSkillEditorsControllerParams { + skillId: string + workspaceId: string + /** Gate the roster fetch off (e.g. built-in template skills have no editors). */ + enabled?: boolean +} + +/** + * Data + mutation controller behind the skill editor surfaces (the detail + * page's Skill Editors section and the Share modal): exposes the roster — + * explicit editors plus derived workspace admins — and the add/remove actions. + * Renderers own only chrome. + */ +export function useSkillEditorsController({ + skillId, + workspaceId, + enabled = true, +}: UseSkillEditorsControllerParams): SkillEditorsController { + const { + data: editors = [], + isPending: editorsLoading, + isError: editorsError, + } = useSkillMembers(skillId, { enabled }) + const { mutateAsync: upsertEditorAsync } = useUpsertSkillMember() + const { mutateAsync: removeEditorAsync } = useRemoveSkillMember() + + const existingEditorEmails = useMemo( + () => new Set(editors.map((editor) => (editor.userEmail ?? '').toLowerCase()).filter(Boolean)), + [editors] + ) + + const addEditor = useCallback( + (target: AddPeopleTarget) => upsertEditorAsync({ skillId, workspaceId, userId: target.userId }), + [upsertEditorAsync, skillId, workspaceId] + ) + + const removeEditor = useCallback( + async (userId: string) => { + try { + await removeEditorAsync({ skillId, workspaceId, userId }) + } catch (error) { + logger.error('Failed to remove skill editor', error) + } + }, + [removeEditorAsync, skillId, workspaceId] + ) + + return { + editors, + editorsLoading, + editorsError, + existingEditorEmails, + addEditor, + removeEditor, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index a8b6e2fb4dd..12e97e2d71c 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -12,10 +12,22 @@ import { chipFieldSurfaceClass, cn, } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' +import { + SKILL_CONTENT_PLACEHOLDER, + SKILL_DESCRIPTION_MAX_LENGTH, + SKILL_DESCRIPTION_PLACEHOLDER, + SKILL_NAME_HINT, + SKILL_NAME_PLACEHOLDER, +} from '@/app/workspace/[workspaceId]/skills/components/skill-copy' import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import' -import { parseSkillMarkdown } from '@/app/workspace/[workspaceId]/skills/components/utils' +import { + isSkillNameConflictError, + parseSkillMarkdown, + validateSkillName, +} from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SkillDefinition } from '@/hooks/queries/skills' import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills' @@ -34,12 +46,9 @@ interface SkillModalProps { open: boolean onOpenChange: (open: boolean) => void onSave: () => void - onDelete?: (skillId: string) => void initialValues?: SkillDefinition } -const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ - interface FieldErrors { name?: string description?: string @@ -49,13 +58,12 @@ interface FieldErrors { type TabValue = 'create' | 'import' -export function SkillModal({ - open, - onOpenChange, - onSave, - onDelete, - initialValues, -}: SkillModalProps) { +const CREATE_TABS = [ + { value: 'create', label: 'Create' }, + { value: 'import', label: 'Import' }, +] as const + +export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillModalProps) { const params = useParams() const workspaceId = params.workspaceId as string @@ -72,7 +80,6 @@ export function SkillModal({ */ const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) - const [saving, setSaving] = useState(false) const [activeTab, setActiveTab] = useState('create') const [prevOpen, setPrevOpen] = useState(false) const [prevInitialValues, setPrevInitialValues] = useState(initialValues) @@ -95,16 +102,13 @@ export function SkillModal({ description !== initialValues.description || content !== initialValues.content + const saving = createSkill.isPending || updateSkill.isPending + const handleSave = async () => { const newErrors: FieldErrors = {} - if (!name.trim()) { - newErrors.name = 'Name is required' - } else if (name.length > 64) { - newErrors.name = 'Name must be 64 characters or less' - } else if (!KEBAB_CASE_REGEX.test(name)) { - newErrors.name = 'Name must be kebab-case (e.g. my-skill)' - } + const nameError = validateSkillName(name) + if (nameError) newErrors.name = nameError if (!description.trim()) { newErrors.description = 'Description is required' @@ -119,8 +123,6 @@ export function SkillModal({ return } - setSaving(true) - try { if (initialValues) { await updateSkill.mutateAsync({ @@ -136,13 +138,11 @@ export function SkillModal({ } onSave() } catch (error) { - const message = - error instanceof Error && error.message.includes('already exists') - ? error.message - : 'Failed to save skill. Please try again.' - setErrors({ general: message }) - } finally { - setSaving(false) + if (isSkillNameConflictError(error)) { + setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) + } else { + setErrors({ general: 'Failed to save skill. Please try again.' }) + } } } @@ -172,8 +172,11 @@ export function SkillModal({ } const isEditing = !!initialValues - const readOnly = !!initialValues?.readOnly - const showFooter = activeTab === 'create' || isEditing + const isBuiltin = !!initialValues?.readOnly + /** New skills are created by the actor (who becomes an editor); existing ones require editor access. */ + const canEditSkill = !initialValues || initialValues.canEdit + const readOnly = isBuiltin || (isEditing && !canEditSkill) + const showFooter = activeTab === 'create' return ( {!isEditing && ( setActiveTab(value as TabValue)} /> @@ -209,10 +209,11 @@ export function SkillModal({ if (errors.name || errors.general) setErrors((prev) => ({ ...prev, name: undefined, general: undefined })) }} - placeholder='my-skill-name' + placeholder={SKILL_NAME_PLACEHOLDER} required error={errors.name} - hint='Lowercase letters, numbers, and hyphens (e.g. my-skill)' + hint={SKILL_NAME_HINT} + disabled={readOnly || saving} /> ({ ...prev, description: undefined, general: undefined })) }} - placeholder='What this skill does and when to use it...' - maxLength={1024} + placeholder={SKILL_DESCRIPTION_PLACEHOLDER} + maxLength={SKILL_DESCRIPTION_MAX_LENGTH} required error={errors.description} + disabled={readOnly || saving} /> @@ -239,8 +241,9 @@ export function SkillModal({ if (errors.content || errors.general) setErrors((prev) => ({ ...prev, content: undefined, general: undefined })) }} - placeholder='Skill instructions in markdown...' + placeholder={SKILL_CONTENT_PLACEHOLDER} minHeight={200} + maxHeight={360} disabled={readOnly || saving} error={!!errors.content} workspaceId={workspaceId} @@ -258,19 +261,7 @@ export function SkillModal({ {showFooter && ( onOpenChange(false)} - cancelDisabled={readOnly} - secondaryActions={ - isEditing && onDelete - ? [ - { - label: 'Delete', - onClick: () => onDelete(initialValues.id), - variant: 'destructive', - disabled: readOnly, - }, - ] - : undefined - } + cancelDisabled={isBuiltin} primaryAction={{ label: saving ? 'Saving...' : isEditing ? 'Update' : 'Create', onClick: handleSave, diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts index debefcf690f..bdb862bf939 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts @@ -1,6 +1,7 @@ import JSZip from 'jszip' +import { isApiClientError } from '@/lib/api/client/errors' -interface ParsedSkill { +export interface ParsedSkill { name: string description: string content: string @@ -86,7 +87,6 @@ function inferNameFromHeading(markdown: string): string { /** * Extracts the SKILL.md content from a ZIP archive. * Searches for a file named SKILL.md at any depth within the archive. - * Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports). */ export async function extractSkillFromZip( data: File | Blob | ArrayBuffer | Uint8Array @@ -113,3 +113,56 @@ export async function extractSkillFromZip( const content = await zip.file(candidates[0])!.async('string') return content } +const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +/** + * Validates a skill name against the same rules the API enforces + * (`skillNameSchema`). Returns the field message, or null when valid. Shared so + * the rule and its copy live in one place across every skill editing surface. + */ +export function validateSkillName(name: string): string | null { + if (!name.trim()) return 'Name is required' + if (name.length > 64) return 'Name must be 64 characters or less' + if (!KEBAB_CASE_REGEX.test(name)) return 'Name must be kebab-case (e.g. my-skill)' + return null +} + +const SKILL_IMPORT_EXTENSIONS = ['.md', '.zip'] as const + +/** ZIPs are read fully in memory to find the SKILL.md, so cap what we'll accept. */ +const MAX_SKILL_ZIP_BYTES = 5 * 1024 * 1024 + +/** `accept` attribute for the skill file pickers. */ +export const SKILL_IMPORT_ACCEPT = SKILL_IMPORT_EXTENSIONS.join(',') + +/** + * Reads a user-picked `.md` or `.zip` into structured skill fields — the shared + * path behind every import entry point (the create page's Import action and the + * canvas modal's Import tab). Throws a user-facing message on an unsupported + * extension, an oversized ZIP, or a ZIP with no SKILL.md inside. + */ +export async function readSkillFile(file: File): Promise { + const name = file.name.toLowerCase() + + if (!SKILL_IMPORT_EXTENSIONS.some((ext) => name.endsWith(ext))) { + throw new Error('Unsupported file type. Use .md or .zip files.') + } + + if (name.endsWith('.zip')) { + if (file.size > MAX_SKILL_ZIP_BYTES) { + throw new Error('ZIP file is too large (max 5 MB)') + } + return parseSkillMarkdown(await extractSkillFromZip(file)) + } + + return parseSkillMarkdown(await file.text()) +} + +/** + * Whether a skill save failed on the per-workspace unique-name constraint + * (HTTP 409). Surfaces as an inline Name-field error at the callsites; the + * server message names the conflicting skill. + */ +export function isSkillNameConflictError(error: unknown): boolean { + return isApiClientError(error) && error.status === 409 +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx new file mode 100644 index 00000000000..e8d291584a7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import { SkillCreate } from '@/app/workspace/[workspaceId]/skills/new/skill-create' + +export const metadata: Metadata = { + title: 'New Skill', +} + +export default async function SkillCreatePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx new file mode 100644 index 00000000000..928862b063b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx @@ -0,0 +1,172 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipLink, toast } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { + CredentialDetailHeading, + CredentialDetailLayout, + UnsavedChangesModal, + useUnsavedChangesGuard, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + type SkillFieldErrors, + SkillFields, +} from '@/app/workspace/[workspaceId]/skills/components/skill-fields' +import { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import' +import { + isSkillNameConflictError, + type ParsedSkill, + parseSkillMarkdown, + validateSkillName, +} from '@/app/workspace/[workspaceId]/skills/components/utils' +import { useCreateSkill } from '@/hooks/queries/skills' + +const logger = createLogger('SkillCreate') + +interface SkillCreateProps { + workspaceId: string +} + +/** + * Full-page skill creation, mirroring the skill detail surface: a fixed action + * bar (Import / Create skill), a heading, and the editable Name / Description / + * Content sections. Importing a SKILL.md prefills all three fields in place. + */ +export function SkillCreate({ workspaceId }: SkillCreateProps) { + const router = useRouter() + const skillsHref = `/workspace/${workspaceId}/skills` + + const createSkill = useCreateSkill() + + const [nameDraft, setNameDraft] = useState('') + const [descriptionDraft, setDescriptionDraft] = useState('') + const [contentDraft, setContentDraft] = useState('') + /** Bumped to remount the seed-once rich Content editor on programmatic sets. */ + const [contentSeed, setContentSeed] = useState(0) + const [errors, setErrors] = useState({}) + + // Drops on success so the guard pops its history sentinel before we navigate — + // otherwise Back from the new skill lands on a stale, empty create form. + const isDirty = + !createSkill.isSuccess && + (!!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim()) + + const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref }) + + const handleCreate = async () => { + if (createSkill.isPending) return + + const newErrors: SkillFieldErrors = {} + const nameError = validateSkillName(nameDraft) + if (nameError) newErrors.name = nameError + if (!descriptionDraft.trim()) newErrors.description = 'Description is required' + if (!contentDraft.trim()) newErrors.content = 'Content is required' + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + return + } + + try { + const created = await createSkill.mutateAsync({ + workspaceId, + skill: { name: nameDraft, description: descriptionDraft, content: contentDraft }, + }) + setErrors({}) + // The upsert responds with the caller's whole skill list (built-ins + // included), not just the new row — match by name, which is unique per + // workspace, rather than trusting the first element. + const createdId = created.find((skill) => skill.name === nameDraft)?.id + router.push(createdId ? `${skillsHref}/${createdId}` : skillsHref) + } catch (error) { + if (isSkillNameConflictError(error)) { + setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) + } else { + toast.error("Couldn't create skill", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + logger.error('Failed to create skill', error) + } + } + + const applyImportedSkill = (data: ParsedSkill) => { + setNameDraft(data.name) + setDescriptionDraft(data.description) + setContentDraft(data.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + } + + /** + * Pasting a full SKILL.md destructures it into the fields. Gated on a real + * YAML `name:` key so a stray `---` or heading-only snippet pastes as + * ordinary content instead of silently overwriting all three fields. + */ + const handleContentPaste = (text: string): boolean => { + const parsed = parseSkillMarkdown(text) + if (!parsed.nameFromFrontmatter) return false + applyImportedSkill(parsed) + return true + } + + const back = ( + + Skills + + ) + + const actions = ( + <> + + + {createSkill.isPending ? 'Creating...' : 'Create'} + + + ) + + return ( + <> + + } + title='New skill' + subtitle='Write a skill, or import an existing SKILL.md' + /> + + { + setNameDraft(value) + if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) + }} + onDescriptionChange={(value) => { + setDescriptionDraft(value) + if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) + }} + onContentChange={(value) => { + setContentDraft(value) + if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) + }} + errors={errors} + contentKey={contentSeed} + workspaceId={workspaceId} + disabled={createSkill.isPending} + onPasteText={handleContentPaste} + /> + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts index 554005fc76d..e78b8167fc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts @@ -3,20 +3,18 @@ import { parseAsString } from 'nuqs/server' /** * Co-located, typed URL query-param definition for the Skills gallery. * - * `skillId` deep-links the skill edit modal to a specific skill. The modal - * opens when a valid id (one that resolves to a loaded skill) is present; - * closing it clears the param. Opening a skill is a destination, so it lands in - * the browser history (`history: 'push'`). The "create new skill" flow has no id - * and stays in local component state. + * `skillId` is a LEGACY deep-link param from when skills edited in a modal on + * this page. Skills now open at `/workspace/[workspaceId]/skills/[skillId]`; + * the gallery reads this param once and redirects there (read-then-strip). */ export const skillIdParam = { key: 'skillId', parser: parseAsString, } as const -/** Opening a skill is a destination → push to history; clear on close. */ +/** Read-once redirect signal — replace history, never linger. */ export const skillIdUrlKeys = { - history: 'push', + history: 'replace', clearOnDefault: true, } as const diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index d045bf88b1b..7004a6c3ebf 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -1,27 +1,23 @@ 'use client' -import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipInput, Search } from '@sim/emcn' -import { createLogger } from '@sim/logger' +import { useEffect, useRef } from 'react' +import { Chip, ChipInput, Search } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight, Plus } from 'lucide-react' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' -import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal' import { skillIdParam, skillIdUrlKeys, skillSearchParam, skillSearchUrlKeys, } from '@/app/workspace/[workspaceId]/skills/search-params' -import { useDeleteSkill, useSkills } from '@/hooks/queries/skills' +import { useSkills } from '@/hooks/queries/skills' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' -const logger = createLogger('SkillsSettings') - const SKILLS_LABEL = 'Skills' interface SkillItemProps { @@ -68,22 +64,31 @@ function SkillSection({ label, children }: SkillSectionProps) { export function Skills() { const params = useParams() + const router = useRouter() const workspaceId = (params?.workspaceId as string) || '' + const skillsHref = `/workspace/${workspaceId}/skills` const { data: skills = [], isLoading, error } = useSkills(workspaceId) - const deleteSkillMutation = useDeleteSkill() const [searchTerm, setSearchTermParam] = useQueryState(skillSearchParam.key, { ...skillSearchParam.parser, ...skillSearchUrlKeys, }) - const [editingSkillId, setEditingSkillId] = useQueryState(skillIdParam.key, { + const [legacySkillId, setLegacySkillId] = useQueryState(skillIdParam.key, { ...skillIdParam.parser, ...skillIdUrlKeys, }) - const [showAddForm, setShowAddForm] = useState(false) - const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) + /** + * Legacy deep links opened the edit modal via `?skillId=`; skills now have a + * dedicated detail page. Redirect once, stripping the param. + */ + const redirectedLegacyId = useRef(false) + useEffect(() => { + if (!legacySkillId || redirectedLegacyId.current) return + redirectedLegacyId.current = true + setLegacySkillId(null, { history: 'replace' }) + router.replace(`${skillsHref}/${legacySkillId}`) + }, [legacySkillId, setLegacySkillId, router, skillsHref]) /** * The input is controlled directly by the instant nuqs value; only the URL @@ -92,9 +97,6 @@ export function Skills() { */ const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam) - /** Derive the skill being edited from the loaded list — never store the object in the URL. */ - const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null - const filteredSkills = skills.filter((s) => { if (!searchTerm.trim()) return true const searchLower = searchTerm.trim().toLowerCase() @@ -104,46 +106,15 @@ export function Skills() { ) }) - const handleDeleteClick = (skillId: string) => { - const s = skills.find((sk) => sk.id === skillId) - if (!s) return - - setSkillToDelete({ id: skillId, name: s.name }) - setShowDeleteDialog(true) - } - - const handleDeleteSkill = async () => { - if (!skillToDelete) return - - setShowDeleteDialog(false) - - try { - await deleteSkillMutation.mutateAsync({ - workspaceId, - skillId: skillToDelete.id, - }) - logger.info(`Deleted skill: ${skillToDelete.id}`) - } catch (error) { - logger.error('Error deleting skill:', error) - } finally { - setSkillToDelete(null) - } - } - - const handleSkillSaved = () => { - setShowAddForm(false) - setEditingSkillId(null) - } - const showNoResults = searchTerm.trim() && filteredSkills.length === 0 - const handleOpenAddForm = () => { - setEditingSkillId(null) - setShowAddForm(true) - } - const addButton = ( - + router.push(`${skillsHref}/new`)} + disabled={isLoading} + leftIcon={Plus} + > Add to Sim ) @@ -177,7 +148,7 @@ export function Skills() { key={s.id} name={s.name} description={s.description} - onClick={() => setEditingSkillId(s.id)} + onClick={() => router.push(`${skillsHref}/${s.id}`)} /> ))} @@ -189,38 +160,6 @@ export function Skills() {
- - { - if (!open) { - setShowAddForm(false) - setEditingSkillId(null) - } - }} - onSave={handleSkillSaved} - onDelete={(skillId) => { - setEditingSkillId(null) - handleDeleteClick(skillId) - }} - initialValues={editingSkill ?? undefined} - /> - -
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index f82386f7e72..b002bc41896 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -238,6 +238,7 @@ export function Chat() { selectedWorkflowOutputs, setSelectedWorkflowOutput, appendMessageContent, + setMessageContent, finalizeMessageStream, getConversationId, clearChat, @@ -256,6 +257,7 @@ export function Chat() { selectedWorkflowOutputs: s.selectedWorkflowOutputs, setSelectedWorkflowOutput: s.setSelectedWorkflowOutput, appendMessageContent: s.appendMessageContent, + setMessageContent: s.setMessageContent, finalizeMessageStream: s.finalizeMessageStream, getConversationId: s.getConversationId, clearChat: s.clearChat, @@ -495,10 +497,21 @@ export function Chat() { async (stream: ReadableStream, responseMessageId: string) => { const reader = stream.getReader() streamReaderRef.current = reader + + /** + * Answer text tracked per block so a `chunk_reset` frame (a live-streamed + * turn resolved to tool calls) can drop one block's contribution. Each + * flush replaces the message content with the joined segments. + */ + const blockOrder: string[] = [] + const blockSegments = new Map() let accumulatedContent = '' + const recomputeContent = () => { + accumulatedContent = blockOrder.map((id) => blockSegments.get(id) ?? '').join('') + } const BATCH_MAX_MS = 50 - let pendingChunks = '' + let contentDirty = false let batchRAF: number | null = null let batchTimer: ReturnType | null = null let lastFlush = 0 @@ -512,9 +525,9 @@ export function Chat() { clearTimeout(batchTimer) batchTimer = null } - if (pendingChunks) { - appendMessageContent(responseMessageId, pendingChunks) - pendingChunks = '' + if (contentDirty) { + setMessageContent(responseMessageId, accumulatedContent) + contentDirty = false } lastFlush = performance.now() } @@ -534,12 +547,17 @@ export function Chat() { let finalError: string | null = null try { - await readSSEEvents<{ event?: string; data?: ExecutionResult; chunk?: string }>(reader, { + await readSSEEvents<{ + event?: string + data?: ExecutionResult + chunk?: string + blockId?: string + }>(reader, { onParseError: (_data, e) => { logger.error('Error parsing stream data:', e) }, onEvent: (json) => { - const { event, data: eventData, chunk: contentChunk } = json + const { event, data: eventData, chunk: contentChunk, blockId } = json if (event === 'final' && eventData) { if ('success' in eventData && !eventData.success) { @@ -548,9 +566,32 @@ export function Chat() { return true } + if (event === 'chunk_reset' && blockId) { + // Drop the block's provisional text and its order slot — the + // final turn re-registers at the end, keeping render order = + // arrival order (separators are recomputed on re-stream). + if (blockSegments.has(blockId)) { + blockSegments.delete(blockId) + const orderIndex = blockOrder.indexOf(blockId) + if (orderIndex !== -1) { + blockOrder.splice(orderIndex, 1) + } + recomputeContent() + contentDirty = true + scheduleFlush() + } + return + } + if (contentChunk) { - accumulatedContent += contentChunk - pendingChunks += contentChunk + const segmentKey = blockId ?? '' + if (!blockSegments.has(segmentKey)) { + blockOrder.push(segmentKey) + blockSegments.set(segmentKey, '') + } + blockSegments.set(segmentKey, blockSegments.get(segmentKey)! + contentChunk) + recomputeContent() + contentDirty = true scheduleFlush() } }, @@ -578,7 +619,14 @@ export function Chat() { focusInput(100) } }, - [appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId] + [ + appendMessageContent, + setMessageContent, + finalizeMessageStream, + focusInput, + selectedOutputs, + activeWorkflowId, + ] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 2d2f1d44b14..4be07fe4eff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -11,6 +11,7 @@ import { Label, Loader, Skeleton, + Switch, TagInput, type TagItem, Textarea, @@ -84,6 +85,8 @@ const initialFormData: ChatFormData = { emails: [], welcomeMessage: 'Hi there! How can I help you today?', selectedOutputBlocks: [], + includeThinking: false, + includeToolCalls: false, } export function ChatDeploy({ @@ -194,6 +197,8 @@ export function ChatDeploy({ (config: { blockId: string; path: string }) => `${config.blockId}_${config.path}` ) : [], + includeThinking: existingChat.includeThinking ?? false, + includeToolCalls: existingChat.includeToolCalls ?? existingChat.includeThinking ?? false, }) if (existingChat.customizations?.imageUrl) { @@ -369,6 +374,34 @@ export function ChatDeploy({ )}
+
+
+ +
+ updateField('includeThinking', checked)} + aria-label='Include thinking' + /> +
+ +
+
+ +
+ updateField('includeToolCalls', checked)} + aria-label='Include tool calls' + /> +
+ (blockId, subBlockId) const [showCreateModal, setShowCreateModal] = useState(false) - const [editingSkill, setEditingSkill] = useState(null) + const [editingSkillId, setEditingSkillId] = useState(null) + const [editingSkillSnapshot, setEditingSkillSnapshot] = useState(null) + + // Prefer the live query cache so the modal reflects concurrent edits, but + // fall back to the click-time snapshot when a background refetch drops the + // skill — otherwise the modal would close mid-edit and silently discard the + // draft; saving surfaces the real server error instead. + const editingSkill = editingSkillId + ? (workspaceSkills.find((s) => s.id === editingSkillId) ?? editingSkillSnapshot) + : null const selectedSkills: StoredSkill[] = useMemo(() => { if (isPreview && previewValue) { @@ -105,7 +113,8 @@ export function SkillInput({ const handleSkillSaved = useCallback(() => { setShowCreateModal(false) - setEditingSkill(null) + setEditingSkillId(null) + setEditingSkillSnapshot(null) }, []) const resolveSkillName = useCallback( @@ -150,7 +159,8 @@ export function SkillInput({ className='flex cursor-pointer items-center justify-between gap-2 rounded-t-[4px] bg-[var(--surface-4)] px-2 py-[6.5px]' onClick={() => { if (fullSkill && !disabled && !isPreview) { - setEditingSkill(fullSkill) + setEditingSkillId(fullSkill.id) + setEditingSkillSnapshot(fullSkill) } }} > @@ -188,7 +198,8 @@ export function SkillInput({ onOpenChange={(isOpen) => { if (!isOpen) { setShowCreateModal(false) - setEditingSkill(null) + setEditingSkillId(null) + setEditingSkillSnapshot(null) } }} onSave={handleSkillSaved} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx index a6df56db169..61012a92c35 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx @@ -4,14 +4,14 @@ import { CODE_LINE_HEIGHT_PX, Code, calculateGutterWidth, + chipFieldSurfaceClass, cn, getCodeEditorProps, highlight, languages, } from '@sim/emcn' -import { Wand2 } from 'lucide-react' import Editor from 'react-simple-code-editor' -import { Button } from '@/components/ui/button' +import type { SchemaParameter } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' import { createEnvVarPattern, createWorkflowVariablePattern, @@ -22,16 +22,15 @@ interface CodeEditorProps { onChange: (value: string) => void language: 'javascript' | 'json' placeholder?: string + /** Layout/sizing only — the chip-field chrome is owned by this component. */ className?: string - gutterClassName?: string + /** Swaps the field border to the error token. */ + error?: boolean minHeight?: string highlightVariables?: boolean onKeyDown?: (e: React.KeyboardEvent) => void disabled?: boolean - schemaParameters?: Array<{ name: string; type: string; description: string; required: boolean }> - showWandButton?: boolean - onWandClick?: () => void - wandButtonDisabled?: boolean + schemaParameters?: SchemaParameter[] } const EMPTY_SCHEMA_PARAMETERS: NonNullable = [] @@ -42,15 +41,12 @@ export function CodeEditor({ language, placeholder = '', className = '', - gutterClassName = '', + error = false, minHeight, highlightVariables = true, onKeyDown, disabled = false, schemaParameters = EMPTY_SCHEMA_PARAMETERS, - showWandButton = false, - onWandClick, - wandButtonDisabled = false, }: CodeEditorProps) { const [visualLineHeights, setVisualLineHeights] = useState([]) @@ -182,21 +178,11 @@ export function CodeEditor({ } return ( - - {showWandButton && onWandClick && ( - - )} - - + + {renderLineNumbers()} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx index ab399baa0de..10dc434c4ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx @@ -1,37 +1,30 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +'use client' + +import { useEffect, useMemo, useState } from 'react' import { - Badge, - Button, ChipConfirmModal, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, ChipModalTabs, - cn, - Input, - Label, - Popover, - PopoverAnchor, - PopoverContent, - PopoverItem, - PopoverScrollArea, - PopoverSection, + toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { AlertCircle, ArrowUp } from 'lucide-react' import { useParams } from 'next/navigation' import { - checkEnvVarTrigger, - EnvVarDropdown, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' -import { - checkTagTrigger, - TagDropdown, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' -import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor' -import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' + CUSTOM_TOOL_DELETE_CONFIRM_TEXT, + CustomToolCodeField, + CustomToolSchemaField, + extractSchemaParameters, + FieldErrorText, + FUNCTION_NAME_LOCKED, + GeneratePromptControl, + useCodeGeneration, + useSchemaGeneration, + validateCustomToolSchema, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor' import { useCreateCustomTool, useCustomTools, @@ -68,6 +61,11 @@ export interface CustomTool { type ToolSection = 'schema' | 'code' +const TOOL_TABS = [ + { value: 'schema', label: 'Schema' }, + { value: 'code', label: 'Code' }, +] as const + export function CustomToolModal({ open, onOpenChange, @@ -89,216 +87,34 @@ export function CustomToolModal({ const [initialFunctionCode, setInitialFunctionCode] = useState('') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDiscardAlert, setShowDiscardAlert] = useState(false) - const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false) - const [schemaPromptInput, setSchemaPromptInput] = useState('') - const schemaPromptInputRef = useRef(null) - - const [isCodePromptActive, setIsCodePromptActive] = useState(false) - const [codePromptInput, setCodePromptInput] = useState('') - const codePromptInputRef = useRef(null) - - const schemaGeneration = useWand({ - wandConfig: { - enabled: true, - maintainHistory: true, - prompt: `You are an expert programmer specializing in creating OpenAI function calling format JSON schemas for custom tools. -Generate ONLY the JSON schema based on the user's request. -The output MUST be a single, valid JSON object, starting with { and ending with }. -The JSON schema MUST follow this specific format: -1. Top-level property "type" must be set to "function" -2. A "function" object containing: - - "name": A concise, camelCase name for the function - - "description": A clear description of what the function does - - "parameters": A JSON Schema object describing the function's parameters with: - - "type": "object" - - "properties": An object containing parameter definitions - - "required": An array of required parameter names -Current schema: {context} - -Do not include any explanations, markdown formatting, or other text outside the JSON object. - -Valid Schema Examples: - -Example 1: -{ - "type": "function", - "function": { - "name": "getWeather", - "description": "Fetches the current weather for a specific location.", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g., San Francisco, CA" - }, - "unit": { - "type": "string", - "description": "Temperature unit", - "enum": ["celsius", "fahrenheit"] - } - }, - "required": ["location"], - "additionalProperties": false - } - } -} + const schemaParameters = useMemo(() => extractSchemaParameters(jsonSchema), [jsonSchema]) -Example 2: -{ - "type": "function", - "function": { - "name": "addItemToOrder", - "description": "Add one quantity of a food item to the order.", - "parameters": { - "type": "object", - "properties": { - "itemName": { - "type": "string", - "description": "The name of the food item to add to order" - }, - "quantity": { - "type": "integer", - "description": "The quantity of the item to add", - "default": 1 - } - }, - "required": ["itemName"], - "additionalProperties": false - } - } -}`, - placeholder: 'Describe the function parameters and structure...', - generationType: 'custom-tool-schema', - }, - currentValue: jsonSchema, - onStreamStart: () => { - setJsonSchema('') - }, - onGeneratedContent: (content) => { - setJsonSchema(content) + const schemaGeneration = useSchemaGeneration({ + jsonSchema, + setJsonSchema: (updater) => { + setJsonSchema(updater) setSchemaError(null) }, - onStreamChunk: (chunk) => { - setJsonSchema((prev) => { - const newSchema = prev + chunk - if (schemaError) setSchemaError(null) - return newSchema - }) + replaceJsonSchema: (value) => { + setJsonSchema(value) + setSchemaError(null) }, }) - const schemaParameters = useMemo(() => { - try { - if (!jsonSchema) return [] - const parsed = JSON.parse(jsonSchema) - const properties = parsed?.function?.parameters?.properties - if (!properties) return [] - - return Object.keys(properties).map((key) => ({ - name: key, - type: properties[key].type || 'any', - description: properties[key].description || '', - required: parsed?.function?.parameters?.required?.includes(key) || false, - })) - } catch { - return [] - } - }, [jsonSchema]) - - const codeGenerationSchemaContext = useMemo(() => { - if (schemaParameters.length === 0) { - return 'Schema parameters: (none defined yet — the user has not added any parameters to the schema)' - } - const lines = schemaParameters.map((p) => { - const requiredLabel = p.required ? 'required' : 'optional' - const description = p.description ? `: ${p.description}` : '' - return `- ${p.name} (${p.type}, ${requiredLabel})${description}` - }) - return `Schema parameters (reference these directly by name in the generated code):\n${lines.join('\n')}` - }, [schemaParameters]) - - const codeGeneration = useWand({ - wandConfig: { - enabled: true, - maintainHistory: true, - prompt: `You are an expert JavaScript programmer. -Generate ONLY the raw body of a JavaScript function based on the user's request. -The code should be executable within an 'async function(params, environmentVariables) {...}' context. -- 'params' (object): Contains input parameters derived from the JSON schema. Reference these directly by name (e.g., 'userId', 'cityName'). Do NOT use 'params.paramName'. -- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env. - -${codeGenerationSchemaContext} - -Current code: {context} - -IMPORTANT FORMATTING RULES: -1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'const apiKey = {{SERVICE_API_KEY}};' not 'const apiKey = "{{SERVICE_API_KEY}}";'). Our system replaces these placeholders before execution. -2. Reference Input Parameters/Workflow Variables: Reference them directly by name (e.g., 'const city = cityName;' or use directly in template strings like \`\${cityName}\`). Do NOT wrap in quotes or angle brackets. -3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}'). -4. Imports: Do NOT include import/require statements unless they are standard Node.js built-in modules (e.g., 'crypto', 'fs'). External libraries are not supported in this context. -5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. -6. Clarity: Write clean, readable code. -7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body. - -Example Scenario: -User Prompt: "Fetch weather data from OpenWeather API. Use the city name passed in as 'cityName' and an API Key stored as the 'OPENWEATHER_API_KEY' environment variable." - -Generated Code: -const apiKey = {{OPENWEATHER_API_KEY}}; -const url = \`https://api.openweathermap.org/data/2.5/weather?q=\${cityName}&appid=\${apiKey}\`; - -try { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(\`API request failed with status \${response.status}: \${await response.text()}\`); - } - - const weatherData = await response.json(); - return weatherData; -} catch (error) { - console.error(\`Error fetching weather data: \${error.message}\`); - throw error; -}`, - placeholder: 'Describe the JavaScript function to generate...', - generationType: 'javascript-function-body', - }, - currentValue: functionCode, - onStreamStart: () => { - setFunctionCode('') - }, - onGeneratedContent: (content) => { - handleFunctionCodeChange(content) + const codeGeneration = useCodeGeneration({ + functionCode, + schemaParameters, + setFunctionCode: (updater) => { + setFunctionCode(updater) setCodeError(null) }, - onStreamChunk: (chunk) => { - setFunctionCode((prev) => { - const newCode = prev + chunk - handleFunctionCodeChange(newCode) - if (codeError) setCodeError(null) - return newCode - }) + replaceFunctionCode: (value) => { + setFunctionCode(value) + setCodeError(null) }, }) - const [showEnvVars, setShowEnvVars] = useState(false) - const [showTags, setShowTags] = useState(false) - const [showSchemaParams, setShowSchemaParams] = useState(false) - const [searchTerm, setSearchTerm] = useState('') - const [cursorPosition, setCursorPosition] = useState(0) - const codeEditorRef = useRef(null) - const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }) - const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0) - const schemaParamItemRefs = useRef>(new Map()) - const createToolMutation = useCreateCustomTool() const updateToolMutation = useUpdateCustomTool() const deleteToolMutation = useDeleteCustomTool() @@ -329,18 +145,6 @@ try { } }, [open]) - useEffect(() => { - if (!showSchemaParams || schemaParamSelectedIndex < 0) return - - const element = schemaParamItemRefs.current.get(schemaParamSelectedIndex) - if (element) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }) - } - }, [schemaParamSelectedIndex, showSchemaParams]) - const resetForm = () => { setJsonSchema('') setFunctionCode('') @@ -351,15 +155,7 @@ try { setActiveSection('schema') setIsEditing(false) setToolId(undefined) - setIsSchemaPromptActive(false) - setIsCodePromptActive(false) - setSchemaPromptInput('') - setCodePromptInput('') setShowDiscardAlert(false) - schemaGeneration.closePrompt() - schemaGeneration.hidePromptInline() - codeGeneration.closePrompt() - codeGeneration.hidePromptInline() } const handleClose = () => { @@ -369,53 +165,18 @@ try { onOpenChange(false) } - const validateSchema = (schema: string): { isValid: boolean; error: string | null } => { - if (!schema) return { isValid: false, error: null } - - try { - const parsed = JSON.parse(schema) - - if (!parsed.type || parsed.type !== 'function') { - return { isValid: false, error: 'Missing "type": "function"' } - } - if (!parsed.function || !parsed.function.name) { - return { isValid: false, error: 'Missing function.name field' } - } - if (!parsed.function.parameters) { - return { isValid: false, error: 'Missing function.parameters object' } - } - if (!parsed.function.parameters.type) { - return { isValid: false, error: 'Missing parameters.type field' } - } - if (parsed.function.parameters.properties === undefined) { - return { isValid: false, error: 'Missing parameters.properties field' } - } - if ( - typeof parsed.function.parameters.properties !== 'object' || - parsed.function.parameters.properties === null - ) { - return { isValid: false, error: 'parameters.properties must be an object' } - } - - return { isValid: true, error: null } - } catch { - return { isValid: false, error: 'Invalid JSON format' } - } - } - - const isSchemaValid = useMemo(() => validateSchema(jsonSchema).isValid, [jsonSchema]) + /** The Generate control and error live in the shared tab row, so both follow the visible editor. */ + const activeGeneration = activeSection === 'schema' ? schemaGeneration : codeGeneration + const activeError = + activeSection === 'schema' ? schemaError : codeGeneration.isStreaming ? null : codeError - const hasChanges = useMemo(() => { - if (!isEditing) return true - return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode - }, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode]) + const isSchemaValid = useMemo(() => validateCustomToolSchema(jsonSchema).isValid, [jsonSchema]) - const hasUnsavedChanges = useMemo(() => { - if (isEditing) { - return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode - } - return jsonSchema.trim().length > 0 || functionCode.trim().length > 0 - }, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode]) + const edited = jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode + const canSave = !isEditing || edited + const hasUnsavedChanges = isEditing + ? edited + : jsonSchema.trim().length > 0 || functionCode.trim().length > 0 const handleCloseAttempt = () => { if (hasUnsavedChanges && !schemaGeneration.isStreaming && !codeGeneration.isStreaming) { @@ -438,7 +199,7 @@ try { return } - const { isValid, error } = validateSchema(jsonSchema) + const { isValid, error } = validateCustomToolSchema(jsonSchema) if (!isValid) { setSchemaError(error) setActiveSection('schema') @@ -509,9 +270,7 @@ try { const errorMessage = getErrorMessage(error, 'Failed to save custom tool') if (errorMessage.includes('Cannot change function name')) { - setSchemaError( - 'Function name cannot be changed after creation. To use a different name, delete this tool and create a new one.' - ) + setSchemaError(FUNCTION_NAME_LOCKED) } else { setSchemaError(errorMessage) } @@ -520,11 +279,10 @@ try { } const handleJsonSchemaChange = (value: string) => { - if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return setJsonSchema(value) if (value.trim()) { - const { error } = validateSchema(value) + const { error } = validateCustomToolSchema(value) setSchemaError(error) } else { setSchemaError(null) @@ -532,265 +290,8 @@ try { } const handleFunctionCodeChange = (value: string) => { - if (codeGeneration.isLoading || codeGeneration.isStreaming) { - setFunctionCode(value) - if (codeError) { - setCodeError(null) - } - return - } - setFunctionCode(value) - if (codeError) { - setCodeError(null) - } - - const textarea = codeEditorRef.current?.querySelector('textarea') - if (textarea) { - const pos = textarea.selectionStart - setCursorPosition(pos) - - const textBeforeCursor = value.substring(0, pos) - const lines = textBeforeCursor.split('\n') - const currentLine = lines.length - const currentCol = lines[lines.length - 1].length - - try { - if (codeEditorRef.current) { - const editorRect = codeEditorRef.current.getBoundingClientRect() - const lineHeight = 21 - - const top = currentLine * lineHeight + 5 - const left = Math.min(currentCol * 8, editorRect.width - 260) - - setDropdownPosition({ top, left }) - } - } catch (error) { - logger.error('Error calculating cursor position:', { error }) - } - - const envVarTrigger = checkEnvVarTrigger(value, pos) - setShowEnvVars(envVarTrigger.show && !codeGeneration.isStreaming) - setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') - - const tagTrigger = checkTagTrigger(value, pos) - setShowTags(tagTrigger.show && !codeGeneration.isStreaming) - if (!tagTrigger.show) { - setActiveSourceBlockId(null) - } - - if (!codeGeneration.isStreaming && schemaParameters.length > 0) { - const schemaParamTrigger = checkSchemaParamTrigger(value, pos, schemaParameters) - if (schemaParamTrigger.show && !showSchemaParams) { - setShowSchemaParams(true) - setSchemaParamSelectedIndex(0) - } else if (!schemaParamTrigger.show && showSchemaParams) { - setShowSchemaParams(false) - } - } - } - } - - const checkSchemaParamTrigger = (text: string, cursorPos: number, parameters: any[]) => { - if (parameters.length === 0) return { show: false, searchTerm: '' } - - const beforeCursor = text.substring(0, cursorPos) - const words = beforeCursor.split(/[\s=();,{}[\]]+/) - const currentWord = words[words.length - 1] || '' - - if (currentWord.length > 0 && /^[a-zA-Z_][\w]*$/.test(currentWord)) { - const matchingParams = parameters.filter((param) => - param.name.toLowerCase().startsWith(currentWord.toLowerCase()) - ) - return { show: matchingParams.length > 0, searchTerm: currentWord, matches: matchingParams } - } - - return { show: false, searchTerm: '' } - } - - const handleEnvVarSelect = (newValue: string) => { - setFunctionCode(newValue) - setShowEnvVars(false) - } - - const handleTagSelect = (newValue: string) => { - setFunctionCode(newValue) - setShowTags(false) - setActiveSourceBlockId(null) - } - - const handleSchemaParamSelect = (paramName: string) => { - const textarea = codeEditorRef.current?.querySelector('textarea') - if (textarea) { - const pos = textarea.selectionStart - const beforeCursor = functionCode.substring(0, pos) - const afterCursor = functionCode.substring(pos) - - const words = beforeCursor.split(/[\s=();,{}[\]]+/) - const currentWord = words[words.length - 1] || '' - const wordStart = beforeCursor.lastIndexOf(currentWord) - - const newValue = beforeCursor.substring(0, wordStart) + paramName + afterCursor - setFunctionCode(newValue) - setShowSchemaParams(false) - - setTimeout(() => { - textarea.focus() - textarea.setSelectionRange(wordStart + paramName.length, wordStart + paramName.length) - }, 0) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - const isSchemaPromptVisible = activeSection === 'schema' && schemaGeneration.isPromptVisible - const isCodePromptVisible = activeSection === 'code' && codeGeneration.isPromptVisible - - if (e.key === 'Escape') { - if (isSchemaPromptVisible) { - schemaGeneration.hidePromptInline() - e.preventDefault() - e.stopPropagation() - return - } - if (isCodePromptVisible) { - codeGeneration.hidePromptInline() - e.preventDefault() - e.stopPropagation() - return - } - if (showEnvVars || showTags || showSchemaParams) { - setShowEnvVars(false) - setShowTags(false) - setShowSchemaParams(false) - e.preventDefault() - e.stopPropagation() - return - } - } - - if (activeSection === 'schema' && schemaGeneration.isStreaming) { - e.preventDefault() - return - } - if (activeSection === 'code' && codeGeneration.isStreaming) { - e.preventDefault() - return - } - - if (showSchemaParams && schemaParameters.length > 0) { - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - e.stopPropagation() - setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1)) - return - case 'ArrowUp': - e.preventDefault() - e.stopPropagation() - setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0)) - return - case 'Enter': - e.preventDefault() - e.stopPropagation() - if (schemaParamSelectedIndex >= 0 && schemaParamSelectedIndex < schemaParameters.length) { - const selectedParam = schemaParameters[schemaParamSelectedIndex] - handleSchemaParamSelect(selectedParam.name) - } - return - case 'Escape': - e.preventDefault() - e.stopPropagation() - setShowSchemaParams(false) - return - case ' ': - case 'Tab': - setShowSchemaParams(false) - return - } - } - - if (showEnvVars || showTags) { - if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) { - e.preventDefault() - e.stopPropagation() - } - } - } - - const handleSchemaWandClick = () => { - if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return - setIsSchemaPromptActive(true) - setSchemaPromptInput('') - setTimeout(() => { - schemaPromptInputRef.current?.focus() - }, 0) - } - - const handleSchemaPromptBlur = () => { - if (!schemaPromptInput.trim() && !schemaGeneration.isStreaming) { - setIsSchemaPromptActive(false) - } - } - - const handleSchemaPromptChange = (value: string) => { - setSchemaPromptInput(value) - } - - const handleSchemaPromptSubmit = () => { - const trimmedPrompt = schemaPromptInput.trim() - if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return - schemaGeneration.generateStream({ prompt: trimmedPrompt }) - setSchemaPromptInput('') - setIsSchemaPromptActive(false) - } - - const handleSchemaPromptKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSchemaPromptSubmit() - } else if (e.key === 'Escape') { - e.preventDefault() - setSchemaPromptInput('') - setIsSchemaPromptActive(false) - } - } - - const handleCodeWandClick = () => { - if (codeGeneration.isLoading || codeGeneration.isStreaming) return - setIsCodePromptActive(true) - setCodePromptInput('') - setTimeout(() => { - codePromptInputRef.current?.focus() - }, 0) - } - - const handleCodePromptBlur = () => { - if (!codePromptInput.trim() && !codeGeneration.isStreaming) { - setIsCodePromptActive(false) - } - } - - const handleCodePromptChange = (value: string) => { - setCodePromptInput(value) - } - - const handleCodePromptSubmit = () => { - const trimmedPrompt = codePromptInput.trim() - if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return - codeGeneration.generateStream({ prompt: trimmedPrompt }) - setCodePromptInput('') - setIsCodePromptActive(false) - } - - const handleCodePromptKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleCodePromptSubmit() - } else if (e.key === 'Escape') { - e.preventDefault() - setCodePromptInput('') - setIsCodePromptActive(false) - } + if (codeError) setCodeError(null) } const handleDelete = async () => { @@ -805,16 +306,15 @@ try { }) logger.info(`Deleted tool: ${toolId}`) - if (onDelete) { - onDelete(toolId) - } + onDelete?.(toolId) handleClose() } catch (error) { logger.error('Error deleting custom tool:', error) - const errorMessage = getErrorMessage(error, 'Failed to delete custom tool') - setSchemaError(`${errorMessage}. Please try again.`) - setActiveSection('schema') + // A delete failure is not a schema problem — keep it out of the Schema slot. + toast.error("Couldn't delete tool", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) setShowDeleteConfirm(false) } } @@ -840,307 +340,47 @@ try { the caret as the body scrolls. */} - setActiveSection(value as ToolSection)} - /> - - {activeSection === 'schema' && ( -
-
-
- - {schemaError && ( -
- - {schemaError} -
- )} -
-
- {!isSchemaPromptActive ? ( - - ) : ( -
- handleSchemaPromptChange(e.target.value)} - onBlur={handleSchemaPromptBlur} - onKeyDown={handleSchemaPromptKeyDown} - disabled={schemaGeneration.isStreaming} - className={cn( - 'h-5 max-w-[200px] flex-1 text-xs', - schemaGeneration.isStreaming && 'text-muted-foreground' - )} - placeholder='Generate...' - /> - -
- )} -
-
- +
+ setActiveSection(value as ToolSection)} /> + {activeError && {activeError}}
+ {/* + Keyed by section so switching tabs resets the inline prompt — one + control drives both wands, and a half-typed Schema prompt must not + carry over and generate Code instead. + */} + activeGeneration.generateStream({ prompt })} + /> +
+ + {activeSection === 'schema' && ( + )} {activeSection === 'code' && ( -
-
-
- - {codeError && !codeGeneration.isStreaming && ( -
- - {codeError} -
- )} -
-
- {!isCodePromptActive ? ( - - ) : ( -
- handleCodePromptChange(e.target.value)} - onBlur={handleCodePromptBlur} - onKeyDown={handleCodePromptKeyDown} - disabled={codeGeneration.isStreaming} - className={cn( - 'h-5 max-w-[200px] flex-1 text-xs', - codeGeneration.isStreaming && 'text-muted-foreground' - )} - placeholder='Generate...' - /> - -
- )} -
-
- {schemaParameters.length > 0 && ( -
-
- - Available parameters: - - {schemaParameters.map((param) => ( - - {param.name} - - ))} - - Start typing a parameter name for autocomplete. - -
-
- )} -
- 0 ? '380px' : '420px'} - className={cn( - 'bg-[var(--bg)]', - codeError && !codeGeneration.isStreaming && 'border-[var(--text-error)]', - (codeGeneration.isLoading || codeGeneration.isStreaming) && - 'cursor-not-allowed opacity-50' - )} - gutterClassName='bg-[var(--bg)]' - highlightVariables={true} - disabled={codeGeneration.isLoading || codeGeneration.isStreaming} - onKeyDown={handleKeyDown} - schemaParameters={schemaParameters} - /> - - {showEnvVars && ( - { - setShowEnvVars(false) - setSearchTerm('') - }} - className='w-64' - style={{ - position: 'absolute', - top: `${dropdownPosition.top}px`, - left: `${dropdownPosition.left}px`, - }} - /> - )} - - {showTags && ( - { - setShowTags(false) - setActiveSourceBlockId(null) - }} - className='w-64' - style={{ - position: 'absolute', - top: `${dropdownPosition.top}px`, - left: `${dropdownPosition.left}px`, - }} - /> - )} - - {showSchemaParams && schemaParameters.length > 0 && ( - { - if (!open) { - setShowSchemaParams(false) - } - }} - colorScheme='inverted' - > - -
- - e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - > - - Available Parameters - {schemaParameters.map((param, index) => ( - setSchemaParamSelectedIndex(index)} - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - handleSchemaParamSelect(param.name) - }} - ref={(el) => { - if (el) { - schemaParamItemRefs.current.set(index, el) - } - }} - > - {param.name} - {param.type && param.type !== 'any' && ( - - {param.type} - - )} - - ))} - - - - )} -
-
+ )} @@ -1181,7 +421,7 @@ try { primaryAction={{ label: isEditing ? 'Update Tool' : 'Save Tool', onClick: handleSave, - disabled: !isSchemaValid || !!schemaError || !hasChanges, + disabled: !isSchemaValid || !!schemaError || !canSave, }} /> )} @@ -1192,13 +432,7 @@ try { onOpenChange={setShowDeleteConfirm} srTitle='Delete Custom Tool' title='Delete Custom Tool' - text={[ - { - text: 'This will permanently delete the tool and remove it from any workflows that are using it.', - error: true, - }, - ' This action cannot be undone.', - ]} + text={CUSTOM_TOOL_DELETE_CONFIRM_TEXT} confirm={{ label: 'Delete', onClick: handleDelete, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index 45d7c0bcc26..c0382809f54 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -27,6 +27,10 @@ import { X, } from 'lucide-react' import Link from 'next/link' +import { + AgentStreamThinkingChrome, + AgentStreamToolCallsChrome, +} from '@/components/agent-stream/agent-stream-chrome' import { OutputContextMenu, StructuredOutput, @@ -567,6 +571,31 @@ export const OutputPanel = React.memo(function OutputPanel({ className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')} onContextMenu={handleOutputPanelContextMenu} > + {!showInput && + (selectedEntry.agentStreamThinking || + (selectedEntry.agentStreamToolCalls && + selectedEntry.agentStreamToolCalls.length > 0)) && ( +
+ {selectedEntry.agentStreamThinking ? ( + + ) : null} + {selectedEntry.agentStreamToolCalls && + selectedEntry.agentStreamToolCalls.length > 0 ? ( + t.status === 'running') + )} + /> + ) : null} +
+ )} {shouldShowCodeDisplay ? ( { - setIsPromptVisible(true) - setPromptInputValue('') - }, []) - - const closePrompt = useCallback(() => { - if (isLoading) return - setIsPromptVisible(false) - setPromptInputValue('') - }, [isLoading]) - const generateStream = useCallback( async ({ prompt }: { prompt: string }) => { if (!prompt) { @@ -294,8 +283,6 @@ export function useWand({ generateStream, showPromptInline, hidePromptInline, - openPrompt, - closePrompt, updatePromptValue, cancelGeneration, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index e950ae934ed..7c708170f14 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -7,11 +7,23 @@ import { generateId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' +import { + type AgentStreamToolCall, + applyToolCallPhase, + settleRunningToolCalls, + snapshotToolCalls, + toolCallKey, +} from '@/components/agent-stream/tool-call-lifecycle' import { requestJson } from '@/lib/api/client/request' import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' -import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events' +import type { + ExecutionPausedData, + StreamDoneData, + StreamThinkingData, + StreamToolData, +} from '@/lib/workflows/executor/execution-events' import { collectInputFormatFiles, isFileFieldType } from '@/lib/workflows/input-format' import { extractTriggerMockPayload, @@ -209,6 +221,122 @@ function buildInputFormatInput(inputFormatValue: unknown): Record | return Object.keys(testInput).length > 0 ? testInput : undefined } +/** + * Thinking deltas arrive per token; batch console writes (same cadence as the + * chat surface) so the terminal does not re-render per delta. + */ +const AGENT_STREAM_THINKING_FLUSH_MS = 50 + +type UpdateConsoleFn = ReturnType<(typeof useTerminalConsoleStore)['getState']>['updateConsole'] + +interface AgentStreamChromeOptions { + executionIdRef: { current: string } + updateConsole: UpdateConsoleFn +} + +/** + * Per-run terminal chrome for live agent stream events: accumulates thinking + * text (batched) and tool chips per block, and settles running chips when a + * block's stream ends, a block errors, or the execution terminates. Shared by + * the full-run and run-from-block paths so both render identical chrome. + */ +function createAgentStreamChrome({ executionIdRef, updateConsole }: AgentStreamChromeOptions) { + const thinkingByBlock = new Map() + const toolCallsByBlock = new Map>() + const toolOrderByBlock = new Map() + const thinkingFlushTimers = new Map>() + + const flushThinking = (blockId: string) => { + const timer = thinkingFlushTimers.get(blockId) + if (timer !== undefined) { + clearTimeout(timer) + thinkingFlushTimers.delete(blockId) + } + const thinking = thinkingByBlock.get(blockId) + if (thinking === undefined) return + updateConsole( + blockId, + { agentStreamThinking: thinking, agentStreamActive: true }, + executionIdRef.current + ) + } + + const settleBlock = (blockId: string, status: 'success' | 'error' | 'cancelled') => { + flushThinking(blockId) + const map = toolCallsByBlock.get(blockId) + const order = toolOrderByBlock.get(blockId) + if (map && order) { + settleRunningToolCalls(map, status) + updateConsole( + blockId, + { + agentStreamActive: false, + agentStreamToolCalls: snapshotToolCalls(order, map), + }, + executionIdRef.current + ) + } else { + updateConsole(blockId, { agentStreamActive: false }, executionIdRef.current) + } + } + + const settleAll = (status: 'success' | 'error' | 'cancelled') => { + const blockIds = new Set([...thinkingByBlock.keys(), ...toolCallsByBlock.keys()]) + for (const blockId of blockIds) { + settleBlock(blockId, status) + } + } + + const onStreamThinking = (data: StreamThinkingData) => { + const prev = thinkingByBlock.get(data.blockId) ?? '' + thinkingByBlock.set(data.blockId, prev + data.text) + if (!thinkingFlushTimers.has(data.blockId)) { + thinkingFlushTimers.set( + data.blockId, + setTimeout(() => flushThinking(data.blockId), AGENT_STREAM_THINKING_FLUSH_MS) + ) + } + } + + const onStreamTool = (data: StreamToolData) => { + if (!toolCallsByBlock.has(data.blockId)) { + toolCallsByBlock.set(data.blockId, new Map()) + toolOrderByBlock.set(data.blockId, []) + } + const map = toolCallsByBlock.get(data.blockId)! + const order = toolOrderByBlock.get(data.blockId)! + + applyToolCallPhase( + map, + order, + { + key: toolCallKey(data.blockId, data.id), + id: data.id, + name: data.name, + phase: data.phase, + status: data.status, + }, + (tool) => tool + ) + + updateConsole( + data.blockId, + { + agentStreamToolCalls: snapshotToolCalls(order, map), + agentStreamActive: true, + }, + executionIdRef.current + ) + } + + const onStreamDone = (data: StreamDoneData) => { + logger.info('Stream done for block:', data.blockId) + settleBlock(data.blockId, 'success') + } + + return { flushThinking, settleBlock, settleAll, onStreamThinking, onStreamTool, onStreamDone } +} + export function useWorkflowExecution() { const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>() const hydrationWorkspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId) @@ -625,6 +753,19 @@ export function useWorkflowExecution() { streamReadingPromises.push(promise) } + /** + * Intermediate-turn reconciliation: drop the block's streamed text + * (chunk_reset frame) and remove its bookkeeping entirely so + * separator counting ignores it and the final turn (or, if none + * re-streams, onBlockComplete's output fallback) starts clean. + */ + const onStreamReset = (blockId: string) => { + if (!streamedChunks.has(blockId)) return + streamedChunks.delete(blockId) + processedFirstChunk.delete(blockId) + safeEnqueue(encodeSSE({ blockId, event: 'chunk_reset' })) + } + // Handle non-streaming blocks (like Function blocks) const onBlockComplete = async (blockId: string, output: any) => { // Skip if this block already had streaming content (avoid duplicates) @@ -682,7 +823,9 @@ export function useWorkflowExecution() { onStream, executionId, onBlockComplete, - 'chat' + 'chat', + undefined, + onStreamReset ) // Check if execution was cancelled @@ -846,7 +989,8 @@ export function useWorkflowExecution() { executionId?: string, onBlockComplete?: (blockId: string, output: any) => Promise, overrideTriggerType?: 'chat' | 'manual' | 'api', - stopAfterBlockId?: string + stopAfterBlockId?: string, + onStreamReset?: (blockId: string) => void ): Promise => { // Use diff workflow for execution when available, regardless of canvas view state const executionWorkflowState = null as { @@ -1063,6 +1207,9 @@ export function useWorkflowExecution() { const activeBlocksSet = new Set() const activeBlockRefCounts = new Map() const streamedChunks = new Map() + const agentStreamChrome = createAgentStreamChrome({ executionIdRef, updateConsole }) + const settleAgentStreamChrome = agentStreamChrome.settleBlock + const settleAllAgentStreamChrome = agentStreamChrome.settleAll const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockStates = new Map() const executedBlockIds = new Set() @@ -1134,7 +1281,11 @@ export function useWorkflowExecution() { onBlockStarted: blockHandlers.onBlockStarted, onBlockCompleted: blockHandlers.onBlockCompleted, - onBlockError: blockHandlers.onBlockError, + onBlockError: (data) => { + // Failures often skip stream:done — settle thinking/tool chrome here. + settleAgentStreamChrome(data.blockId, 'error') + blockHandlers.onBlockError(data) + }, onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted, onStreamChunk: (data) => { @@ -1167,10 +1318,19 @@ export function useWorkflowExecution() { } }, - onStreamDone: (data) => { - logger.info('Stream done for block:', data.blockId) + onStreamChunkReset: (data) => { + // Live-streamed text belonged to an intermediate turn (tools + // follow); the final turn re-streams as regular chunks. + streamedChunks.delete(data.blockId) + if (onStreamReset && isExecutingFromChat) { + onStreamReset(data.blockId) + } }, + onStreamThinking: agentStreamChrome.onStreamThinking, + onStreamTool: agentStreamChrome.onStreamTool, + onStreamDone: agentStreamChrome.onStreamDone, + onExecutionCompleted: (data) => { executionFinished = true if ( @@ -1181,6 +1341,8 @@ export function useWorkflowExecution() { ) return + settleAllAgentStreamChrome(data.success ? 'success' : 'error') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) reconcileFinalBlockLogs( @@ -1278,6 +1440,9 @@ export function useWorkflowExecution() { ) return + // HITL pause mid tool-loop — open tools never got an end event. + settleAllAgentStreamChrome('cancelled') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) reconcileFinalBlockLogs( @@ -1322,6 +1487,8 @@ export function useWorkflowExecution() { ) return + settleAllAgentStreamChrome('error') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) } @@ -1364,6 +1531,8 @@ export function useWorkflowExecution() { ) return + settleAllAgentStreamChrome('cancelled') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) } @@ -1807,6 +1976,7 @@ export function useWorkflowExecution() { const executedBlockIds = new Set() const activeBlocksSet = new Set() const activeBlockRefCounts = new Map() + const agentStreamChrome = createAgentStreamChrome({ executionIdRef, updateConsole }) const isCurrentRunFromBlockExecution = () => { return ( Boolean(executionIdRef.current) && @@ -1860,11 +2030,20 @@ export function useWorkflowExecution() { onBlockStarted: blockHandlers.onBlockStarted, onBlockCompleted: blockHandlers.onBlockCompleted, - onBlockError: blockHandlers.onBlockError, + onBlockError: (data) => { + // Failures often skip stream:done — settle thinking/tool chrome here. + agentStreamChrome.settleBlock(data.blockId, 'error') + blockHandlers.onBlockError(data) + }, onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted, + onStreamThinking: agentStreamChrome.onStreamThinking, + onStreamTool: agentStreamChrome.onStreamTool, + onStreamDone: agentStreamChrome.onStreamDone, + onExecutionCompleted: (data) => { if (!isCurrentRunFromBlockExecution()) return + agentStreamChrome.settleAll(data.success ? 'success' : 'error') const executionId = executionIdRef.current reconcileFinalBlockLogs(updateConsole, workflowId, executionId, data.finalBlockLogs) finishRunningEntries(workflowId, executionId) @@ -1899,6 +2078,8 @@ export function useWorkflowExecution() { onExecutionPaused: (data) => { if (!isCurrentRunFromBlockExecution()) return + // HITL pause mid tool-loop — open tools never got an end event. + agentStreamChrome.settleAll('cancelled') const executionId = executionIdRef.current reconcileFinalBlockLogs(updateConsole, workflowId, executionId, data.finalBlockLogs) finishRunningEntries(workflowId, executionId) @@ -1918,6 +2099,7 @@ export function useWorkflowExecution() { onExecutionError: (data) => { if (!isCurrentRunFromBlockExecution()) return + agentStreamChrome.settleAll('error') const executionId = executionIdRef.current const isWorkflowModified = data.error?.includes('Block not found in workflow') || @@ -1944,6 +2126,7 @@ export function useWorkflowExecution() { onExecutionCancelled: (data) => { if (!isCurrentRunFromBlockExecution()) return + agentStreamChrome.settleAll('cancelled') const executionId = executionIdRef.current handleExecutionCancelledConsole({ workflowId, diff --git a/apps/sim/app/workspace/providers/socket-join-controller.ts b/apps/sim/app/workspace/providers/socket-join-controller.ts index b3fb18b5e88..4f46a30d1fd 100644 --- a/apps/sim/app/workspace/providers/socket-join-controller.ts +++ b/apps/sim/app/workspace/providers/socket-join-controller.ts @@ -92,6 +92,19 @@ export class SocketJoinController { } handleWorkflowDeleted(workflowId: string): SocketJoinDeleteResult { + return this.evictFromWorkflow(workflowId) + } + + /** + * Handles the realtime server evicting this socket because the user's access + * to the workflow was revoked. Identical control flow to a deletion: block + * re-join of that workflow and drop any current/pending membership. + */ + handleAccessRevoked(workflowId: string): SocketJoinDeleteResult { + return this.evictFromWorkflow(workflowId) + } + + private evictFromWorkflow(workflowId: string): SocketJoinDeleteResult { const commands = this.takeRetryResetCommands( this.retryWorkflowId === workflowId ? null : this.retryWorkflowId ) diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index af6df147547..65a8678ccfa 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -12,6 +12,7 @@ import { } from 'react' import { createLogger } from '@sim/logger' import type { + AccessRevokedBroadcast, CursorUpdateBroadcast, OperationConfirmedBroadcast, OperationFailedBroadcast, @@ -104,6 +105,7 @@ interface SocketContextType { onCursorUpdate: (handler: (data: CursorUpdateBroadcast) => void) => void onSelectionUpdate: (handler: (data: SelectionUpdateBroadcast) => void) => void onWorkflowDeleted: (handler: (data: WorkflowDeletedBroadcast) => void) => void + onAccessRevoked: (handler: (data: AccessRevokedBroadcast) => void) => void onWorkflowReverted: (handler: (data: WorkflowRevertedBroadcast) => void) => void onWorkflowUpdated: (handler: (data: WorkflowUpdatedBroadcast) => void) => void onWorkflowDeployed: (handler: (data: WorkflowDeployedBroadcast) => void) => void @@ -135,6 +137,7 @@ const SocketContext = createContext({ onCursorUpdate: () => {}, onSelectionUpdate: () => {}, onWorkflowDeleted: () => {}, + onAccessRevoked: () => {}, onWorkflowReverted: () => {}, onWorkflowUpdated: () => {}, onWorkflowDeployed: () => {}, @@ -182,6 +185,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { cursorUpdate?: (data: CursorUpdateBroadcast) => void selectionUpdate?: (data: SelectionUpdateBroadcast) => void workflowDeleted?: (data: WorkflowDeletedBroadcast) => void + accessRevoked?: (data: AccessRevokedBroadcast) => void workflowReverted?: (data: WorkflowRevertedBroadcast) => void workflowUpdated?: (data: WorkflowUpdatedBroadcast) => void workflowDeployed?: (data: WorkflowDeployedBroadcast) => void @@ -595,6 +599,20 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowDeleted?.(data) }) + socketInstance.on('access-revoked', (data: AccessRevokedBroadcast) => { + logger.warn(`Access to workflow ${data.workflowId} has been revoked`) + const result = joinControllerRef.current.handleAccessRevoked(data.workflowId) + if (result.shouldClearCurrent) { + clearJoinedWorkflowState(true) + // Surface the same blocked-join UX as a denied join: persistent + // toast plus read-only enforcement while the user is still on the + // revoked workflow. + setBlockedJoinWorkflowId(data.workflowId) + } + executeJoinCommands(result.commands) + eventHandlers.current.accessRevoked?.(data) + }) + socketInstance.on('workflow-reverted', (data: WorkflowRevertedBroadcast) => { logger.info(`Workflow ${data.workflowId} has been reverted to deployed state`) eventHandlers.current.workflowReverted?.(data) @@ -1154,6 +1172,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowDeleted = handler }, []) + const onAccessRevoked = useCallback((handler: (data: AccessRevokedBroadcast) => void) => { + eventHandlers.current.accessRevoked = handler + }, []) + const onWorkflowReverted = useCallback((handler: (data: WorkflowRevertedBroadcast) => void) => { eventHandlers.current.workflowReverted = handler }, []) @@ -1202,6 +1224,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onCursorUpdate, onSelectionUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, @@ -1232,6 +1255,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onCursorUpdate, onSelectionUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 89c0d6e9912..fb2e9756c38 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -13,6 +13,7 @@ import { getMaxTemperature, getModelsWithDeepResearch, getModelsWithoutMemory, + getModelsWithPromptCaching, getModelsWithReasoningEffort, getModelsWithThinking, getModelsWithVerbosity, @@ -29,6 +30,7 @@ const logger = createLogger('AgentBlock') const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() const MODELS_WITH_THINKING = getModelsWithThinking() +const MODELS_WITH_PROMPT_CACHING = getModelsWithPromptCaching() const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() @@ -308,6 +310,19 @@ Return ONLY the JSON array.`, value: MODELS_WITH_THINKING, }, }, + { + id: 'promptCaching', + title: 'Prompt Caching', + type: 'switch', + description: + 'Cache the system prompt and tool definitions so repeat runs reuse them at a reduced rate. Writing the cache costs more than a normal request, so this pays off when the same prompt runs repeatedly.', + defaultValue: false, + mode: 'advanced', + condition: { + field: 'model', + value: MODELS_WITH_PROMPT_CACHING, + }, + }, ...getProviderCredentialSubBlocks(), { @@ -646,6 +661,10 @@ Return ONLY the JSON array.`, type: 'string', description: 'Thinking level for models with extended thinking (Anthropic Claude, Gemini 3)', }, + promptCaching: { + type: 'boolean', + description: 'Cache the system prompt and tool definitions on models that support it', + }, tools: { type: 'json', description: 'Available tools configuration' }, skills: { type: 'json', description: 'Selected skills configuration' }, }, diff --git a/apps/sim/blocks/blocks/buffer.ts b/apps/sim/blocks/blocks/buffer.ts index 5a7f76a86d9..761ed4f258b 100644 --- a/apps/sim/blocks/blocks/buffer.ts +++ b/apps/sim/blocks/blocks/buffer.ts @@ -125,7 +125,7 @@ export const BufferBlock: BlockConfig = { id: 'mediaUpload', title: 'Media', type: 'file-upload', - canonicalParamId: 'media', + canonicalParamId: 'mediaSource', acceptedTypes: 'image/png,image/jpeg,image/gif,image/webp,video/mp4,video/quicktime', mode: 'basic', multiple: false, @@ -135,8 +135,17 @@ export const BufferBlock: BlockConfig = { id: 'mediaRef', title: 'Media', type: 'short-input', - canonicalParamId: 'media', - placeholder: 'Public image/video URL or a file reference from a previous block', + canonicalParamId: 'mediaSource', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', + condition: { field: 'operation', value: POST_EDIT_OPS }, + }, + { + id: 'mediaUrl', + title: 'Media URL', + type: 'short-input', + placeholder: 'Public image or video URL', + description: 'Alternative to Media.', mode: 'advanced', condition: { field: 'operation', value: POST_EDIT_OPS }, }, @@ -285,7 +294,8 @@ export const BufferBlock: BlockConfig = { params: (params) => { const result: Record = {} for (const [key, value] of Object.entries(params)) { - if (key === 'media') continue + // Media is resolved below into the single `media` param the tools declare. + if (key === 'mediaSource' || key === 'mediaUrl') continue if (value === undefined || value === null || value === '') continue if (key === 'limit') { const limit = Number(value) @@ -295,23 +305,14 @@ export const BufferBlock: BlockConfig = { result[key] = value } - // Collapse basic/advanced media inputs into a single file reference, - // passing plain URL strings (advanced mode) through untouched. JSON-ish - // strings that normalize to nothing (e.g. "[]" from an empty file - // reference) are dropped rather than treated as URLs. - const media = params.media - const normalizedMedia = normalizeFileInput(media, { single: true }) + // An uploaded or referenced file wins; otherwise fall back to the separate URL + // field. `mediaSource` only ever holds a file, so no value sniffing is needed. + const normalizedMedia = normalizeFileInput(params.mediaSource, { single: true }) + const mediaUrl = typeof params.mediaUrl === 'string' ? params.mediaUrl.trim() : '' if (normalizedMedia) { result.media = normalizedMedia - } else if (typeof media === 'string' && media.trim() !== '') { - const trimmed = media.trim() - let parsesAsJson = true - try { - JSON.parse(trimmed) - } catch { - parsesAsJson = false - } - if (!parsesAsJson) result.media = trimmed + } else if (mediaUrl) { + result.media = mediaUrl } return result @@ -333,7 +334,8 @@ export const BufferBlock: BlockConfig = { schedulingType: { type: 'string', description: 'Scheduling type (automatic or notification)' }, dueAt: { type: 'string', description: 'Publish time (ISO 8601)' }, saveToDraft: { type: 'boolean', description: 'Save the post as a draft' }, - media: { type: 'string', description: 'Image or video attachment (file or public URL)' }, + mediaSource: { type: 'json', description: 'Image or video file to attach' }, + mediaUrl: { type: 'string', description: 'Public image or video URL to attach' }, mediaType: { type: 'string', description: 'Attachment type override: auto, image, or video', diff --git a/apps/sim/blocks/blocks/elevenlabs.ts b/apps/sim/blocks/blocks/elevenlabs.ts index cfe4e0da9b3..67c2f2fcc10 100644 --- a/apps/sim/blocks/blocks/elevenlabs.ts +++ b/apps/sim/blocks/blocks/elevenlabs.ts @@ -88,12 +88,24 @@ export const ElevenLabsBlock: BlockConfig = { id: 'audioFile', title: 'Audio File', type: 'file-upload', + canonicalParamId: 'audioFile', placeholder: 'Upload an audio file', + mode: 'basic', multiple: false, acceptedTypes: '.mp3,.m4a,.wav,.webm,.ogg,.flac,.aac,.opus', condition: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, required: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, }, + { + id: 'audioFileRef', + title: 'Audio File', + type: 'short-input', + canonicalParamId: 'audioFile', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', + condition: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, + required: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, + }, { id: 'modelId', diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 9349dfbb07d..74e81cb07d8 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -17,7 +17,7 @@ interface MothershipResponse extends ToolResponse { export const MothershipBlock: BlockConfig = { type: 'mothership', - name: 'Sim', + name: 'Sim Chat', description: 'Talk to Sim', longDescription: 'The Sim block sends messages to Sim, which has access to subagents, integration tools, and workspace context. Use it to perform complex multi-step reasoning, cross-service queries, or any task that benefits from the full Sim intelligence within a workflow.', diff --git a/apps/sim/blocks/blocks/telegram.test.ts b/apps/sim/blocks/blocks/telegram.test.ts new file mode 100644 index 00000000000..43ec34667a0 --- /dev/null +++ b/apps/sim/blocks/blocks/telegram.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { TelegramBlock } from '@/blocks/blocks/telegram' + +const MEDIA_OPS = [ + { operation: 'telegram_send_photo', key: 'photo', label: 'Photo' }, + { operation: 'telegram_send_video', key: 'video', label: 'Video' }, + { operation: 'telegram_send_audio', key: 'audio', label: 'Audio' }, + { operation: 'telegram_send_animation', key: 'animation', label: 'Animation' }, +] as const + +const base = { botToken: 'bot-token', chatId: '12345' } + +function mapParams(params: Record) { + return TelegramBlock.tools.config!.params!({ ...base, ...params }) as Record +} + +const userFile = { + id: 'f1', + name: 'pic.jpg', + url: 'https://storage.example/pic.jpg', + size: 1024, + type: 'image/jpeg', + key: 'execution/ws/wf/ex/pic.jpg', +} + +describe('Telegram media params', () => { + /** + * These tools POST JSON, and Telegram's media field is a String — a file_id, or an HTTP URL + * Telegram fetches itself. An uploaded file therefore has to become its https URL; sending + * the UserFile object would put a JSON object where Telegram requires a string. + */ + for (const { operation, key, label } of MEDIA_OPS) { + describe(operation, () => { + it('sends an uploaded file as its https URL, not as an object', () => { + const mapped = mapParams({ operation, [`${key}Source`]: userFile }) + expect(mapped[key]).toBe('https://storage.example/pic.jpg') + }) + + it('sends a file reference resolved from advanced mode as its https URL', () => { + const mapped = mapParams({ operation, [`${key}Source`]: JSON.stringify(userFile) }) + expect(mapped[key]).toBe('https://storage.example/pic.jpg') + }) + + it('passes a file_id through untouched', () => { + const mapped = mapParams({ operation, [key]: ' AgACAgIAAxkBAAI ' }) + expect(mapped[key]).toBe('AgACAgIAAxkBAAI') + }) + + it('passes a public HTTP URL through untouched', () => { + const mapped = mapParams({ operation, [key]: 'https://example.com/a.jpg' }) + expect(mapped[key]).toBe('https://example.com/a.jpg') + }) + + it('still accepts a file reference left in the legacy field', () => { + // Before the upload pair existed this field held either a reference or a string. + const mapped = mapParams({ operation, [key]: JSON.stringify(userFile) }) + expect(mapped[key]).toBe('https://storage.example/pic.jpg') + }) + + it('reports a clear error when the file has no https URL to fetch', () => { + expect(() => + mapParams({ operation, [`${key}Source`]: { ...userFile, url: '/api/files/serve/local' } }) + ).toThrow(/https URL for Telegram to fetch/) + }) + + it(`throws when no ${key} is supplied`, () => { + expect(() => mapParams({ operation })).toThrow(`${label} is required.`) + }) + }) + } + + it('keeps each media pair to a file upload plus a file reference', () => { + for (const { key } of MEDIA_OPS) { + const members = TelegramBlock.subBlocks.filter((s) => s.canonicalParamId === `${key}Source`) + expect(members.map((s) => s.type)).toEqual(['file-upload', 'short-input']) + expect(members.map((s) => s.mode)).toEqual(['basic', 'advanced']) + // The file_id/URL field is deliberately outside the pair. + const direct = TelegramBlock.subBlocks.find((s) => s.id === key) + expect(direct?.canonicalParamId).toBeUndefined() + } + }) +}) diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 296d818cbef..c0c3df40ce4 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -1,10 +1,38 @@ import { TelegramIcon } from '@/components/icons' +import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import type { TelegramResponse } from '@/tools/telegram/types' import { getTrigger } from '@/triggers' +/** + * Telegram media params take a single string — a `file_id`, an HTTP URL Telegram fetches + * itself, or a multipart upload. These tools post JSON, so an uploaded file is sent as its + * https URL rather than as an object, which Telegram would reject. + * + * `direct` also accepts a file reference for workflows built before the upload pair existed, + * when that field held either shape. `normalizeFileInput` parses it rather than guessing. + */ +function resolveTelegramMedia(fileSource: unknown, direct: unknown, label: string): string { + const file = normalizeFileInput(fileSource ?? direct, { single: true }) + if (file) { + const url = resolveHttpsUrlFromFileInput(file) + if (!url) { + throw new Error( + `${label} must be reachable at an https URL for Telegram to fetch. Configure cloud storage, or pass a file_id or public URL instead.` + ) + } + return url + } + + const value = typeof direct === 'string' ? direct.trim() : '' + if (!value) { + throw new Error(`${label} is required.`) + } + return value +} + export const TelegramBlock: BlockConfig = { type: 'telegram', name: 'Telegram', @@ -83,88 +111,136 @@ export const TelegramBlock: BlockConfig = { id: 'photoFile', title: 'Photo', type: 'file-upload', - canonicalParamId: 'photo', + canonicalParamId: 'photoSource', placeholder: 'Upload photo', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 5, acceptedTypes: '.jpg,.jpeg,.png,.gif,.webp', condition: { field: 'operation', value: 'telegram_send_photo' }, }, { - id: 'photo', + id: 'photoRef', title: 'Photo', type: 'short-input', - canonicalParamId: 'photo', - placeholder: 'Reference photo from previous blocks or enter URL/file_id', + canonicalParamId: 'photoSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_photo' }, + }, + { + id: 'photo', + title: 'Photo ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Photo. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_photo' }, }, { id: 'videoFile', title: 'Video', type: 'file-upload', - canonicalParamId: 'video', + canonicalParamId: 'videoSource', placeholder: 'Upload video', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 20, acceptedTypes: '.mp4,.mov,.avi,.mkv,.webm', condition: { field: 'operation', value: 'telegram_send_video' }, }, { - id: 'video', + id: 'videoRef', title: 'Video', type: 'short-input', - canonicalParamId: 'video', - placeholder: 'Reference video from previous blocks or enter URL/file_id', + canonicalParamId: 'videoSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_video' }, + }, + { + id: 'video', + title: 'Video ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Video. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_video' }, }, { id: 'audioFile', title: 'Audio', type: 'file-upload', - canonicalParamId: 'audio', + canonicalParamId: 'audioSource', placeholder: 'Upload audio', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 20, acceptedTypes: '.mp3,.m4a,.wav,.ogg,.flac', condition: { field: 'operation', value: 'telegram_send_audio' }, }, { - id: 'audio', + id: 'audioRef', title: 'Audio', type: 'short-input', - canonicalParamId: 'audio', - placeholder: 'Reference audio from previous blocks or enter URL/file_id', + canonicalParamId: 'audioSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_audio' }, + }, + { + id: 'audio', + title: 'Audio ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Audio. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_audio' }, }, { id: 'animationFile', title: 'Animation', type: 'file-upload', - canonicalParamId: 'animation', + canonicalParamId: 'animationSource', placeholder: 'Upload animation (GIF)', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 20, acceptedTypes: '.gif,.mp4', condition: { field: 'operation', value: 'telegram_send_animation' }, }, { - id: 'animation', + id: 'animationRef', title: 'Animation', type: 'short-input', - canonicalParamId: 'animation', - placeholder: 'Reference animation from previous blocks or enter URL/file_id', + canonicalParamId: 'animationSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_animation' }, + }, + { + id: 'animation', + title: 'Animation ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Animation. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_animation' }, }, // File upload (basic mode) for Send Document @@ -467,58 +543,34 @@ export const TelegramBlock: BlockConfig = { messageId: requireNumber(params.messageId, 'Message ID'), } case 'telegram_send_photo': { - // photo is the canonical param for both basic (photoFile) and advanced modes - const photoSource = normalizeFileInput(params.photo, { - single: true, - }) - if (!photoSource) { - throw new Error('Photo is required.') - } return { ...commonParams, - photo: photoSource, + photo: resolveTelegramMedia(params.photoSource, params.photo, 'Photo'), caption: params.caption, } } case 'telegram_send_video': { - // video is the canonical param for both basic (videoFile) and advanced modes - const videoSource = normalizeFileInput(params.video, { - single: true, - }) - if (!videoSource) { - throw new Error('Video is required.') - } return { ...commonParams, - video: videoSource, + video: resolveTelegramMedia(params.videoSource, params.video, 'Video'), caption: params.caption, } } case 'telegram_send_audio': { - // audio is the canonical param for both basic (audioFile) and advanced modes - const audioSource = normalizeFileInput(params.audio, { - single: true, - }) - if (!audioSource) { - throw new Error('Audio is required.') - } return { ...commonParams, - audio: audioSource, + audio: resolveTelegramMedia(params.audioSource, params.audio, 'Audio'), caption: params.caption, } } case 'telegram_send_animation': { - // animation is the canonical param for both basic (animationFile) and advanced modes - const animationSource = normalizeFileInput(params.animation, { - single: true, - }) - if (!animationSource) { - throw new Error('Animation is required.') - } return { ...commonParams, - animation: animationSource, + animation: resolveTelegramMedia( + params.animationSource, + params.animation, + 'Animation' + ), caption: params.caption, } } @@ -653,10 +705,17 @@ export const TelegramBlock: BlockConfig = { botToken: { type: 'string', description: 'Telegram bot token' }, chatId: { type: 'string', description: 'Chat identifier' }, text: { type: 'string', description: 'Message text' }, - photo: { type: 'json', description: 'Photo (UserFile or URL/file_id)' }, - video: { type: 'json', description: 'Video (UserFile or URL/file_id)' }, - audio: { type: 'json', description: 'Audio (UserFile or URL/file_id)' }, - animation: { type: 'json', description: 'Animation (UserFile or URL/file_id)' }, + photoSource: { type: 'json', description: 'Photo file to send' }, + photo: { type: 'string', description: 'Telegram file_id or public HTTP URL for the photo' }, + videoSource: { type: 'json', description: 'Video file to send' }, + video: { type: 'string', description: 'Telegram file_id or public HTTP URL for the video' }, + audioSource: { type: 'json', description: 'Audio file to send' }, + audio: { type: 'string', description: 'Telegram file_id or public HTTP URL for the audio' }, + animationSource: { type: 'json', description: 'Animation file to send' }, + animation: { + type: 'string', + description: 'Telegram file_id or public HTTP URL for the animation', + }, files: { type: 'array', description: 'Files to attach (UserFile array)' }, caption: { type: 'string', description: 'Caption for media' }, messageId: { type: 'string', description: 'Target message ID' }, diff --git a/apps/sim/blocks/blocks/whatsapp.ts b/apps/sim/blocks/blocks/whatsapp.ts index 16eba080700..ded94bedff3 100644 --- a/apps/sim/blocks/blocks/whatsapp.ts +++ b/apps/sim/blocks/blocks/whatsapp.ts @@ -1,16 +1,24 @@ import { WhatsAppIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { WhatsAppResponse } from '@/tools/whatsapp/types' import { getTrigger } from '@/triggers' +/** Yes/No dropdowns serialize as `'true'`/`'false'` strings; the tools expect booleans. */ +function toOptionalBoolean(value: unknown): boolean | undefined { + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + return undefined +} + export const WhatsAppBlock: BlockConfig = { type: 'whatsapp', name: 'WhatsApp', description: 'Send WhatsApp messages', authMode: AuthMode.ApiKey, longDescription: - 'Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API.', + 'Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API. Free-form messages only reach a recipient within 24 hours of their last message to you — outside that window WhatsApp requires a pre-approved template.', docsLink: 'https://docs.sim.ai/integrations/whatsapp', category: 'tools', integrationType: IntegrationType.Communication, @@ -30,6 +38,8 @@ export const WhatsAppBlock: BlockConfig = { { label: 'Send Interactive', id: 'send_interactive' }, { label: 'Send Reaction', id: 'send_reaction' }, { label: 'Mark As Read', id: 'mark_read' }, + { label: 'Upload Media', id: 'upload_media' }, + { label: 'Download Media', id: 'get_media' }, ], defaultValue: 'send_message', }, @@ -38,14 +48,25 @@ export const WhatsAppBlock: BlockConfig = { title: 'Recipient Phone Number', type: 'short-input', placeholder: 'Enter phone number with country code (e.g., +1234567890)', - condition: { field: 'operation', value: 'mark_read', not: true }, - required: { field: 'operation', value: 'mark_read', not: true }, + description: 'Include the plus sign and country calling code.', + condition: { + field: 'operation', + value: ['mark_read', 'upload_media', 'get_media'], + not: true, + }, + required: { + field: 'operation', + value: ['mark_read', 'upload_media', 'get_media'], + not: true, + }, }, { id: 'message', title: 'Message', type: 'long-input', placeholder: 'Enter your message', + description: + 'Free-form text (max 4096 characters). WhatsApp only delivers this within 24 hours of the recipient last messaging you — outside that window use Send Template.', condition: { field: 'operation', value: 'send_message' }, required: { field: 'operation', value: 'send_message' }, }, @@ -90,6 +111,44 @@ export const WhatsAppBlock: BlockConfig = { condition: { field: 'operation', value: 'send_template' }, required: false, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a WhatsApp template components JSON array based on the user's description. +Each component fills the variables of one part of an approved template. + +Current components: {context} + +Format: +[ + { + "type": "header", + "parameters": [{ "type": "text", "text": "value" }] + }, + { + "type": "body", + "parameters": [ + { "type": "text", "text": "value" }, + { "type": "currency", "currency": { "fallback_value": "$100", "code": "USD", "amount_1000": 100000 } }, + { "type": "date_time", "date_time": { "fallback_value": "February 25, 2026" } } + ] + }, + { + "type": "button", + "sub_type": "url", + "index": "0", + "parameters": [{ "type": "text", "text": "suffix" }] + } +] + +RULES: +1. Parameters are positional - they fill {{1}}, {{2}}, ... in template order +2. Only include the components the template actually declares +3. "button" components require both "sub_type" and a string "index" + +Return ONLY the valid JSON array - no explanations, no extra text.`, + placeholder: 'Describe the template variables to fill...', + generationType: 'json-object', + }, }, { id: 'mediaType', @@ -100,35 +159,68 @@ export const WhatsAppBlock: BlockConfig = { { label: 'Document', id: 'document' }, { label: 'Video', id: 'video' }, { label: 'Audio', id: 'audio' }, + { label: 'Sticker', id: 'sticker' }, ], defaultValue: 'image', condition: { field: 'operation', value: 'send_media' }, required: { field: 'operation', value: 'send_media' }, }, { - id: 'mediaLink', - title: 'Media Link', - type: 'short-input', - placeholder: 'Public HTTPS URL of the media', + id: 'sendMediaFile', + title: 'Media', + type: 'file-upload', + canonicalParamId: 'mediaSource', + placeholder: 'Upload media to send', + description: + 'Uploaded to WhatsApp, then sent. Use Upload Media when sending the same file repeatedly.', + mode: 'basic', + multiple: false, + required: false, + acceptedTypes: + '.jpg,.jpeg,.png,.webp,.mp4,.3gp,.aac,.amr,.mp3,.m4a,.ogg,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt', condition: { field: 'operation', value: 'send_media' }, + }, + { + id: 'sendMediaFileRef', + title: 'Media', + type: 'short-input', + canonicalParamId: 'mediaSource', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', required: false, + condition: { field: 'operation', value: 'send_media' }, }, { id: 'mediaId', title: 'Media ID', type: 'short-input', - placeholder: 'ID of media uploaded to WhatsApp', - description: 'Provide a Media Link or a Media ID.', - condition: { field: 'operation', value: 'send_media' }, + placeholder: 'ID of media already uploaded to WhatsApp', + description: 'Alternative to Media. Reuses an upload for 30 days.', + mode: 'advanced', required: false, + condition: { field: 'operation', value: 'send_media' }, + }, + { + id: 'mediaLink', + title: 'Media Link', + type: 'short-input', + placeholder: 'Public HTTPS URL of the media', + description: 'Alternative to Media. WhatsApp fetches and caches this URL for 10 minutes.', mode: 'advanced', + required: false, + condition: { field: 'operation', value: 'send_media' }, }, { id: 'caption', title: 'Caption', type: 'long-input', placeholder: 'Optional caption (image, video, or document)', - condition: { field: 'operation', value: 'send_media' }, + description: 'Max 1024 characters. Not supported for audio or sticker media.', + condition: { + field: 'operation', + value: 'send_media', + and: { field: 'mediaType', value: ['image', 'video', 'document'] }, + }, required: false, mode: 'advanced', }, @@ -137,15 +229,32 @@ export const WhatsAppBlock: BlockConfig = { title: 'File Name', type: 'short-input', placeholder: 'Optional file name for documents', - condition: { field: 'operation', value: 'send_media' }, + condition: { + field: 'operation', + value: 'send_media', + and: { field: 'mediaType', value: 'document' }, + }, required: false, mode: 'advanced', }, + { + id: 'interactiveType', + title: 'Interactive Type', + type: 'dropdown', + options: [ + { label: 'Reply Buttons', id: 'button' }, + { label: 'List', id: 'list' }, + ], + defaultValue: 'button', + condition: { field: 'operation', value: 'send_interactive' }, + required: { field: 'operation', value: 'send_interactive' }, + }, { id: 'bodyText', title: 'Body Text', type: 'long-input', placeholder: 'Main message body', + description: 'Max 1024 characters with reply buttons, 4096 with a list.', condition: { field: 'operation', value: 'send_interactive' }, required: { field: 'operation', value: 'send_interactive' }, }, @@ -154,34 +263,104 @@ export const WhatsAppBlock: BlockConfig = { title: 'Reply Buttons', type: 'long-input', placeholder: '[{"type":"reply","reply":{"id":"yes","title":"Yes"}}]', - description: 'JSON array of reply buttons (max 3). Provide buttons or sections.', - condition: { field: 'operation', value: 'send_interactive' }, - required: false, + description: 'JSON array of reply buttons (max 3, title max 20 characters).', + condition: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'button' }, + }, + required: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'button' }, + }, + wandConfig: { + enabled: true, + prompt: `Generate a WhatsApp interactive reply buttons JSON array based on the user's description. + +Current buttons: {context} + +Format: +[ + { "type": "reply", "reply": { "id": "unique_id", "title": "Button Label" } } +] + +RULES: +1. Maximum 3 buttons +2. "title" must be 20 characters or fewer and must be unique across the buttons +3. "id" must be unique, 256 characters or fewer, and is what the webhook reports back when tapped + +Return ONLY the valid JSON array - no explanations, no extra text.`, + placeholder: 'Describe the reply buttons to offer...', + generationType: 'json-object', + }, }, { id: 'listButtonText', title: 'List Button Text', type: 'short-input', placeholder: 'e.g., Menu', - description: 'Label for the button that opens the list. Required when sending a list.', - condition: { field: 'operation', value: 'send_interactive' }, - required: false, + description: 'Label for the button that opens the list (max 20 characters).', + condition: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, + required: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, }, { id: 'sections', title: 'List Sections', type: 'long-input', placeholder: '[{"title":"Section","rows":[{"id":"r1","title":"Row 1"}]}]', - description: 'JSON array of list sections. Provide sections or buttons.', - condition: { field: 'operation', value: 'send_interactive' }, - required: false, - mode: 'advanced', + description: 'JSON array of list sections (max 10 sections, 10 rows total).', + condition: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, + required: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, + wandConfig: { + enabled: true, + prompt: `Generate a WhatsApp interactive list sections JSON array based on the user's description. + +Current sections: {context} + +Format: +[ + { + "title": "Section Title", + "rows": [ + { "id": "unique_id", "title": "Row Title", "description": "Optional detail" } + ] + } +] + +RULES: +1. Maximum 10 sections and 10 rows total across all sections +2. Section "title" and row "title" must be 24 characters or fewer +3. Row "description" is optional and must be 72 characters or fewer +4. Row "id" must be unique across all sections, 200 characters or fewer, and is what the webhook reports back when selected + +Return ONLY the valid JSON array - no explanations, no extra text.`, + placeholder: 'Describe the list options to offer...', + generationType: 'json-object', + }, }, { id: 'headerText', title: 'Header Text', type: 'short-input', placeholder: 'Optional plain-text header', + description: 'Max 60 characters.', condition: { field: 'operation', value: 'send_interactive' }, required: false, mode: 'advanced', @@ -191,6 +370,7 @@ export const WhatsAppBlock: BlockConfig = { title: 'Footer Text', type: 'short-input', placeholder: 'Optional footer text', + description: 'Max 60 characters.', condition: { field: 'operation', value: 'send_interactive' }, required: false, mode: 'advanced', @@ -211,6 +391,56 @@ export const WhatsAppBlock: BlockConfig = { condition: { field: 'operation', value: 'send_reaction' }, required: false, }, + { + id: 'uploadFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'file', + placeholder: 'Upload a file', + description: + 'WhatsApp limits: images 5 MB, video and audio 16 MB, documents 100 MB, stickers 500 KB.', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: + '.jpg,.jpeg,.png,.webp,.mp4,.3gp,.aac,.amr,.mp3,.m4a,.ogg,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt', + condition: { field: 'operation', value: 'upload_media' }, + }, + { + id: 'uploadFileRef', + title: 'File', + type: 'short-input', + canonicalParamId: 'file', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', + required: true, + condition: { field: 'operation', value: 'upload_media' }, + }, + { + id: 'downloadMediaId', + title: 'Media ID', + type: 'short-input', + placeholder: 'Media ID from an incoming message', + description: + 'The media asset ID on an incoming message, not the message ID (wamid). Incoming media IDs expire after 7 days.', + condition: { field: 'operation', value: 'get_media' }, + required: { field: 'operation', value: 'get_media' }, + }, + { + id: 'showTypingIndicator', + title: 'Show Typing Indicator', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + defaultValue: 'false', + description: + 'Display a typing indicator to the sender. Dismissed once you reply or after 25 seconds.', + condition: { field: 'operation', value: 'mark_read' }, + required: false, + mode: 'advanced', + }, { id: 'phoneNumberId', title: 'WhatsApp Phone Number ID', @@ -236,29 +466,54 @@ export const WhatsAppBlock: BlockConfig = { 'whatsapp_send_interactive', 'whatsapp_send_reaction', 'whatsapp_mark_read', + 'whatsapp_upload_media', + 'whatsapp_get_media', ], config: { tool: (params) => `whatsapp_${params.operation || 'send_message'}`, - params: (params) => ({ - ...params, - previewUrl: - params.previewUrl === 'true' ? true : params.previewUrl === 'false' ? false : undefined, - }), + params: (params) => { + const { + interactiveType, + file: uploadedFile, + mediaSource, + downloadMediaId, + ...rest + } = params + // Both file canonical pairs resolve to the tool's single `file` param; only one + // operation is ever active, so they cannot collide. + const file = normalizeFileInput(uploadedFile ?? mediaSource, { single: true }) + + return { + ...rest, + previewUrl: toOptionalBoolean(params.previewUrl), + showTypingIndicator: toOptionalBoolean(params.showTypingIndicator), + ...(file ? { file } : {}), + ...(params.operation === 'get_media' ? { mediaId: downloadMediaId } : {}), + } + }, }, }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, phoneNumber: { type: 'string', description: 'Recipient phone number' }, message: { type: 'string', description: 'Message text' }, - previewUrl: { type: 'boolean', description: 'Whether to render a preview for the first URL' }, + previewUrl: { type: 'string', description: 'Whether to render a preview for the first URL' }, templateName: { type: 'string', description: 'Approved template name' }, languageCode: { type: 'string', description: 'Template language code (e.g., en_US)' }, components: { type: 'json', description: 'Template components with variable parameters' }, - mediaType: { type: 'string', description: 'Media type: image, document, video, or audio' }, + mediaType: { + type: 'string', + description: 'Media type: image, document, video, audio, or sticker', + }, + mediaSource: { type: 'json', description: 'Media file to send' }, + mediaId: { type: 'string', description: 'ID of media already uploaded to WhatsApp' }, mediaLink: { type: 'string', description: 'Public HTTPS URL of the media' }, - mediaId: { type: 'string', description: 'ID of media uploaded to WhatsApp' }, caption: { type: 'string', description: 'Caption for image, video, or document media' }, filename: { type: 'string', description: 'File name for document media' }, + interactiveType: { + type: 'string', + description: 'Interactive message variant: button or list', + }, bodyText: { type: 'string', description: 'Interactive message body text' }, headerText: { type: 'string', description: 'Interactive message header text' }, footerText: { type: 'string', description: 'Interactive message footer text' }, @@ -267,6 +522,12 @@ export const WhatsAppBlock: BlockConfig = { sections: { type: 'json', description: 'List sections for an interactive message' }, messageId: { type: 'string', description: 'Target message ID (wamid)' }, emoji: { type: 'string', description: 'Reaction emoji (empty to remove)' }, + showTypingIndicator: { + type: 'string', + description: 'Whether to show a typing indicator when marking a message as read', + }, + file: { type: 'json', description: 'File to upload to WhatsApp' }, + downloadMediaId: { type: 'string', description: 'Media asset ID to download' }, phoneNumberId: { type: 'string', description: 'WhatsApp phone number ID' }, accessToken: { type: 'string', description: 'WhatsApp access token' }, }, @@ -275,7 +536,8 @@ export const WhatsAppBlock: BlockConfig = { messageId: { type: 'string', description: 'WhatsApp message identifier' }, messageStatus: { type: 'string', - description: 'Initial delivery state returned by the send API, such as accepted or paused', + description: + 'Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status.', }, messagingProduct: { type: 'string', @@ -294,6 +556,22 @@ export const WhatsAppBlock: BlockConfig = { description: 'Recipient contacts returned by the send API (each item includes input and wa_id)', }, + mediaId: { + type: 'string', + description: 'Media asset ID from Upload Media, or the ID downloaded by Download Media', + }, + file: { + type: 'file', + description: 'Media downloaded by Download Media, stored as a workflow file', + }, + fileName: { type: 'string', description: 'Name of the file uploaded by Upload Media' }, + mimeType: { type: 'string', description: 'MIME type of the uploaded or downloaded media' }, + size: { type: 'number', description: 'Size in bytes of the file uploaded by Upload Media' }, + fileSize: { type: 'number', description: 'Size in bytes of the downloaded media' }, + sha256: { + type: 'string', + description: 'SHA-256 hash WhatsApp reported for the downloaded media', + }, eventType: { type: 'string', description: 'Webhook classification such as incoming_message, message_status, or mixed', @@ -322,6 +600,14 @@ export const WhatsAppBlock: BlockConfig = { description: 'Type of the first incoming message in the batch, such as text, image, or system', }, + mediaMimeType: { + type: 'string', + description: 'MIME type of the first incoming media message in the webhook batch', + }, + caption: { + type: 'string', + description: 'Caption on the first incoming image, video, or document message', + }, status: { type: 'string', description: 'First outgoing message status in the batch, such as sent, delivered, or read', @@ -443,23 +729,37 @@ export const WhatsAppBlockMeta = { { name: 'send-appointment-reminder', description: - 'Send a WhatsApp message reminding a contact of an upcoming appointment or booking.', + 'Send a WhatsApp template message reminding a contact of an upcoming appointment or booking.', content: - '# Send a WhatsApp Appointment Reminder\n\nNotify a contact about an upcoming appointment over WhatsApp.\n\n## Steps\n1. Gather the recipient phone number in full international format (country code, no plus sign or spaces as the API expects).\n2. Compose a short, clear message with the date, time, location or link, and any action the contact should take.\n3. Send the message with the WhatsApp send operation.\n4. Capture the returned message ID and delivery state.\n\n## Output\nConfirm the recipient, the message sent, and the message ID. If the send was rejected, report the reason rather than retrying blindly.', + '# Send a WhatsApp Appointment Reminder\n\nNotify a contact about an upcoming appointment over WhatsApp.\n\nA reminder is business-initiated, so it almost always falls outside the 24-hour customer service window. Use the Send Template operation with a pre-approved template — a free-form text message will be rejected unless the contact messaged you within the last 24 hours.\n\n## Steps\n1. Gather the recipient phone number with the plus sign and country calling code (e.g. +14155552671).\n2. Pick the approved appointment-reminder template and its language code (e.g. en_US).\n3. Fill the template components positionally — the parameters map to {{1}}, {{2}}, ... in the order the template declares them (date, time, location or link).\n4. Send with Send Template and capture the returned message ID.\n\n## Output\nConfirm the recipient, the template used, and the message ID. If the send was rejected, report the WhatsApp error code and message rather than retrying blindly — a template rejection usually means the template name, language, or parameter count is wrong.', }, { name: 'send-order-update', description: 'Notify a customer over WhatsApp about an order status change such as shipment or delivery.', content: - '# Send a WhatsApp Order Update\n\nKeep a customer informed about their order via WhatsApp.\n\n## Steps\n1. Collect the customer phone number in full international format and the order details (number, status, tracking, ETA).\n2. Write a concise update that states what changed and includes the tracking link if available.\n3. Send the message and record the message ID and delivery state.\n\n## Output\nReport which customer was notified, the order referenced, and the message ID. Flag any number that could not be reached.', + '# Send a WhatsApp Order Update\n\nKeep a customer informed about their order via WhatsApp.\n\n## Steps\n1. Collect the customer phone number with the plus sign and country calling code, plus the order details (number, status, tracking, ETA).\n2. Choose the operation by conversation state: if the customer messaged you within the last 24 hours, Send Message works; otherwise use Send Template with an approved shipping-update template.\n3. State what changed and include the tracking link if available.\n4. Send and record the message ID.\n\n## Output\nReport which customer was notified, the order referenced, and the message ID. Flag any number that could not be reached, including the WhatsApp error code.', }, { name: 'broadcast-to-segment', description: - 'Send a personalized WhatsApp message to each contact in an audience list, one at a time.', + 'Send a personalized WhatsApp template message to each contact in an audience list, one at a time.', + content: + "# Broadcast a WhatsApp Message to a Segment\n\nDeliver a personalized message to every contact in a list.\n\nBroadcasts are business-initiated, so every send must use the Send Template operation with a pre-approved marketing or utility template. Free-form text will fail for any contact who has not messaged you in the last 24 hours.\n\n## Steps\n1. Read the audience list, each row holding a phone number and any personalization fields.\n2. For each contact, build the template components by filling the positional parameters with that row's values.\n3. Send one template message per contact, pacing them to stay within the account throughput limit.\n4. Track which sends succeeded and which failed.\n\n## Output\nReturn counts of messages sent and failed, plus a short list of failed recipients with the WhatsApp error code for each.", + }, + { + name: 'send-interactive-menu', + description: + 'Ask a WhatsApp contact to choose from reply buttons or a selectable list, then act on the reply.', + content: + '# Send a WhatsApp Interactive Menu\n\nOffer a contact a set of tappable choices instead of asking them to type a reply.\n\n## Steps\n1. Decide the variant: reply buttons for up to 3 short choices, a list for up to 10 options grouped into sections.\n2. Write the body text and give every choice a stable id — the webhook reports that id back when the contact taps it, so it is what your workflow branches on.\n3. Respect the limits: button titles 20 characters, list row titles 24, row descriptions 72, list menu button 20.\n4. Send with Send Interactive and record the message ID.\n5. Handle the reply in a workflow triggered by the WhatsApp webhook, matching on the returned id.\n\n## Output\nConfirm the choices offered and the message ID. When a reply arrives, report which option the contact selected.', + }, + { + name: 'acknowledge-incoming-message', + description: + 'Mark an incoming WhatsApp message as read, show a typing indicator, and react to it.', content: - '# Broadcast a WhatsApp Message to a Segment\n\nDeliver a personalized message to every contact in a list.\n\n## Steps\n1. Read the audience list, each row holding a phone number and any personalization fields.\n2. For each contact, build the message by filling in their name and relevant details.\n3. Send messages one per contact, pacing them to stay within WhatsApp rate and template limits.\n4. Track which sends succeeded and which failed.\n\n## Output\nReturn counts of messages sent and failed, plus a short list of failed recipients with the failure reason.', + '# Acknowledge an Incoming WhatsApp Message\n\nGive a contact immediate feedback that their message landed while a longer reply is being prepared.\n\n## Steps\n1. Take the message ID (wamid) from the WhatsApp webhook trigger payload.\n2. Call Mark As Read with that message ID so the sender sees blue checkmarks. Enable the typing indicator when a reply is coming — it clears once you respond or after 25 seconds.\n3. Optionally use Send Reaction with an emoji to acknowledge the message inline. Reactions are not delivered for messages over 30 days old, deleted messages, or other reactions.\n4. Send the substantive reply with Send Message — the incoming message opened the 24-hour window, so free-form text is allowed here.\n\n## Output\nConfirm the message was marked read and report the reply that was sent.', }, ], } as const satisfies BlockMeta diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx new file mode 100644 index 00000000000..30b5984b96b --- /dev/null +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -0,0 +1,286 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/lib/copilot/tools/tool-display', () => ({ + humanizeToolName: (name: string) => name, +})) + +vi.mock('@/components/ui', () => ({ + ShimmerText: ({ + as: Comp = 'span', + children, + className, + ...props + }: { + as?: 'span' | 'div' + children: React.ReactNode + className?: string + [key: string]: unknown + }) => { + const Tag = Comp + return ( + + {children} + + ) + }, +})) + +import { + AgentStreamThinkingChrome, + AgentStreamToolCallsChrome, +} from '@/components/agent-stream/agent-stream-chrome' +import type { AgentStreamToolCall } from '@/components/agent-stream/tool-call-lifecycle' + +function renderChrome(props: { thinking: string; isStreaming?: boolean }): { + container: HTMLDivElement + rerender: (next: { thinking: string; isStreaming?: boolean }) => void + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + const mount = (p: { thinking: string; isStreaming?: boolean }) => { + act(() => { + root.render() + }) + } + + mount(props) + + return { + container, + rerender: (next) => mount(next), + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('AgentStreamThinkingChrome', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('opens while streaming with Thinking… label and scrollable body', () => { + const { container, unmount } = renderChrome({ + thinking: 'step one', + isStreaming: true, + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Thinking…') + expect( + container + .querySelector('[data-testid="agent-stream-thinking-label"]') + ?.getAttribute('data-shimmer') + ).toBe('true') + expect(body.className).toContain('max-h-40') + expect(body.className).toContain('overflow-y-auto') + expect(body.getAttribute('data-shimmer')).toBeNull() + expect( + container + .querySelector('[data-testid="agent-stream-thinking-shimmer"]') + ?.getAttribute('data-shimmer') + ).toBe('true') + expect(body.textContent).toContain('step one') + }) + + it('auto-collapses when streaming ends and shows Thought for a moment', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'long internal chain', + isStreaming: true, + }) + mounts.push(unmount) + + rerender({ thinking: 'long internal chain', isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toContain('Thought for a moment') + expect( + container + .querySelector('[data-testid="agent-stream-thinking-label"]') + ?.getAttribute('data-shimmer') + ).toBeNull() + expect(container.querySelector('[data-testid="agent-stream-thinking-shimmer"]')).toBeNull() + expect(body.className).toContain('text-[var(--text-muted)]') + }) + + it('stays open after manual reopen once collapsed', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'reason\n'.repeat(40), + isStreaming: true, + }) + mounts.push(unmount) + + rerender({ thinking: 'reason\n'.repeat(40), isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + Object.defineProperty(body, 'scrollTop', { value: 80, writable: true, configurable: true }) + + act(() => { + toggle.click() + }) + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(body.scrollTop).toBe(0) + expect(body.textContent).toContain('reason') + + // Re-render with same done state should not force-close a user pin. + rerender({ thinking: 'reason\n'.repeat(40), isStreaming: false }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) + + it('re-opens when a new streaming phase starts', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'first', + isStreaming: false, + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + // Initial non-streaming starts closed. + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + rerender({ thinking: 'first then more', isStreaming: true }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Thinking…') + }) +}) + +const sampleTools: AgentStreamToolCall[] = [ + { + key: 'agent-1:t1', + id: 't1', + name: 'http_request', + displayName: 'Http Request', + status: 'success', + }, +] + +function renderToolsChrome(props: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }): { + container: HTMLDivElement + rerender: (next: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }) => void + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + const mount = (p: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }) => { + act(() => { + root.render( + + ) + }) + } + + mount(props) + + return { + container, + rerender: (next) => mount(next), + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('AgentStreamToolCallsChrome', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('opens while tools are streaming and auto-collapses when they finish', () => { + const { container, rerender, unmount } = renderToolsChrome({ + isStreaming: true, + toolCalls: [{ ...sampleTools[0], status: 'running' }], + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-tools-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Using tools…') + expect(container.textContent).toContain('Http Request') + + rerender({ isStreaming: false, toolCalls: sampleTools }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toContain('Tools') + expect(container.textContent).not.toContain('Http Request') + }) + + it('stays open after manual reopen once collapsed', () => { + const { container, rerender, unmount } = renderToolsChrome({ isStreaming: true }) + mounts.push(unmount) + + rerender({ isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-tools-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + act(() => { + toggle.click() + }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(container.textContent).toContain('Http Request') + + rerender({ isStreaming: false }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) +}) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx new file mode 100644 index 00000000000..5c34c1dd25a --- /dev/null +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { cn } from '@sim/emcn' +import { Check, ChevronDown, Circle, Square, X } from 'lucide-react' +import type { + AgentStreamToolCall, + AgentStreamToolStatus, +} from '@/components/agent-stream/tool-call-lifecycle' +import { ShimmerText } from '@/components/ui' +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' + +/** Distance from bottom (px) within which we keep following new thinking text. */ +const STICK_TO_BOTTOM_THRESHOLD_PX = 24 + +/** + * Open / pinned-open / auto-collapse state shared by both chrome panels: + * streaming forces the panel open, the panel auto-collapses when streaming + * ends unless the user pinned it open, and manual toggles while idle pin it. + */ +function useAutoCollapseOpen(isStreaming: boolean, onOpen?: (streaming: boolean) => void) { + const [open, setOpen] = useState(!!isStreaming) + const [userPinnedOpen, setUserPinnedOpen] = useState(false) + const wasStreamingRef = useRef(!!isStreaming) + + useEffect(() => { + const wasStreaming = wasStreamingRef.current + wasStreamingRef.current = !!isStreaming + + if (isStreaming) { + setOpen(true) + setUserPinnedOpen(false) + return + } + + if (wasStreaming && !isStreaming && !userPinnedOpen) { + setOpen(false) + } + }, [isStreaming, userPinnedOpen]) + + const toggle = () => { + setOpen((prev) => { + const next = !prev + if (!isStreaming) { + setUserPinnedOpen(next) + } else { + setUserPinnedOpen(false) + } + if (next) { + onOpen?.(!!isStreaming) + } + return next + }) + } + + return { open, toggle } +} + +export interface AgentStreamThinkingChromeProps { + thinking: string + isStreaming?: boolean +} + +export function AgentStreamThinkingChrome({ + thinking, + isStreaming = false, +}: AgentStreamThinkingChromeProps) { + const [stickToBottom, setStickToBottom] = useState(true) + const [overflowing, setOverflowing] = useState(false) + const scrollRef = useRef(null) + /** After a manual reopen of completed thoughts, jump to the top once. */ + const reopenFromTopRef = useRef(false) + + const { open, toggle } = useAutoCollapseOpen(isStreaming, (streaming) => { + if (streaming) { + setStickToBottom(true) + } else { + // ChatGPT-style: reopen completed thoughts from the top. + setStickToBottom(false) + reopenFromTopRef.current = true + } + }) + + useEffect(() => { + if (isStreaming) { + setStickToBottom(true) + } + }, [isStreaming]) + + useLayoutEffect(() => { + const el = scrollRef.current + if (!el || !open) return + + setOverflowing(el.scrollHeight > el.clientHeight + 1) + + if (reopenFromTopRef.current && !isStreaming) { + el.scrollTop = 0 + reopenFromTopRef.current = false + return + } + + if (isStreaming && stickToBottom) { + el.scrollTop = el.scrollHeight + } + }, [thinking, open, isStreaming, stickToBottom]) + + const handleScroll = () => { + const el = scrollRef.current + if (!el) return + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + const nearBottom = distanceFromBottom <= STICK_TO_BOTTOM_THRESHOLD_PX + setStickToBottom(nearBottom) + setOverflowing(el.scrollHeight > el.clientHeight + 1) + } + + const label = isStreaming ? 'Thinking…' : 'Thought for a moment' + + return ( +
+ + +
+
+
+
+ {/* Shimmer on an inner node — never on the scroll shell. background-clip:text + on overflow-y-auto breaks scroll/overflow in Chromium. */} + {isStreaming ? ( + + {thinking} + + ) : ( + thinking + )} +
+ {open && isStreaming && overflowing && ( +
+ )} +
+
+
+
+ ) +} + +function ToolStatusIcon({ status }: { status: AgentStreamToolStatus }) { + if (status === 'success') { + return + } + if (status === 'error') { + return + } + if (status === 'cancelled') { + return + } + return +} + +export interface AgentStreamToolCallsChromeProps { + toolCalls: AgentStreamToolCall[] + isStreaming?: boolean +} + +export function AgentStreamToolCallsChrome({ + toolCalls, + isStreaming, +}: AgentStreamToolCallsChromeProps) { + const { open, toggle } = useAutoCollapseOpen(!!isStreaming) + + return ( +
+ + {open && ( +
    + {toolCalls.map((tool) => ( +
  • + + + {tool.displayName || humanizeToolName(tool.name)} + {tool.status === 'running' ? '…' : ''} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/apps/sim/components/agent-stream/tool-call-lifecycle.ts b/apps/sim/components/agent-stream/tool-call-lifecycle.ts new file mode 100644 index 00000000000..a338d8d63a5 --- /dev/null +++ b/apps/sim/components/agent-stream/tool-call-lifecycle.ts @@ -0,0 +1,117 @@ +/** + * Shared client-side reducer for agent-stream tool chips. + * + * The public chat hook, the canvas execution hook, and the terminal console + * store all consume the same tool lifecycle (keyed ordered upsert on + * start/end, settle-running-on-terminal). This module is the single + * implementation so the three surfaces cannot drift. + */ + +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' + +export type AgentStreamToolStatus = 'running' | 'success' | 'error' | 'cancelled' + +export interface AgentStreamToolCall { + key: string + id: string + name: string + displayName?: string + status: AgentStreamToolStatus +} + +/** Terminal statuses a running chip can settle to. */ +export type AgentStreamToolTerminalStatus = Exclude + +/** Canonical chip key — unique per block and tool call within an execution. */ +export function toolCallKey(blockId: string, id: string): string { + return `${blockId}:${id}` +} + +/** Normalizes a wire `status` into a terminal chip status (default success). */ +export function resolveToolCallEndStatus(status?: string): AgentStreamToolTerminalStatus { + return status === 'error' || status === 'cancelled' ? status : 'success' +} + +/** + * Applies a tool lifecycle phase to a keyed map + insertion-order list. + * `extend` lets callers add surface-specific fields (e.g. chat's `blockId`). + */ +export function applyToolCallPhase( + map: Map, + order: string[], + event: { key: string; id: string; name: string; phase: 'start' | 'end'; status?: string }, + extend: (tool: AgentStreamToolCall) => T +): void { + const { key } = event + if (event.phase === 'start') { + if (!map.has(key)) { + order.push(key) + } + map.set( + key, + extend({ + key, + id: event.id, + name: event.name, + displayName: humanizeToolName(event.name), + status: 'running', + }) + ) + return + } + + const endStatus = resolveToolCallEndStatus(event.status) + const existing = map.get(key) + if (!existing) { + order.push(key) + map.set( + key, + extend({ + key, + id: event.id, + name: event.name, + displayName: humanizeToolName(event.name), + status: endStatus, + }) + ) + return + } + map.set(key, { ...existing, status: endStatus }) +} + +/** Settles every still-running chip in the map to a terminal status. */ +export function settleRunningToolCalls( + map: Map, + status: AgentStreamToolTerminalStatus +): void { + for (const [key, tool] of map) { + if (tool.status === 'running') { + map.set(key, { ...tool, status }) + } + } +} + +/** List variant of {@link settleRunningToolCalls} for immutable store entries. */ +export function settleRunningToolCallList( + toolCalls: T[] | undefined, + status: AgentStreamToolTerminalStatus +): T[] | undefined { + return toolCalls?.map((tool) => (tool.status === 'running' ? { ...tool, status } : tool)) +} + +/** Ordered snapshot of the map, or undefined when no chips exist. */ +export function snapshotToolCalls( + order: string[], + map: Map +): T[] | undefined { + if (order.length === 0) return undefined + return order.map((key) => map.get(key)).filter((tool): tool is T => Boolean(tool)) +} + +/** True while any chip is still running. */ +export function anyToolCallRunning(map: Map): boolean { + for (const tool of map.values()) { + if (tool.status === 'running') return true + } + return false +} diff --git a/apps/sim/components/permissions/add-people-modal.tsx b/apps/sim/components/permissions/add-people-modal.tsx new file mode 100644 index 00000000000..dbfe6d129d6 --- /dev/null +++ b/apps/sim/components/permissions/add-people-modal.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { partitionSettledFailures, resolveAddEmail } from '@/lib/workspaces/sharing' +import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { MEMBER_ROLE_OPTIONS, type MemberRole } from './member-role-options' + +const logger = createLogger('AddPeopleModal') + +export interface AddPeopleTarget { + email: string + userId: string +} + +interface AddPeopleModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Lowercased emails that already have access — rejected as duplicates. */ + existingMemberEmails: Set + /** Grants one person the role; a rejection surfaces as a partial failure. */ + addMember: (target: AddPeopleTarget, role: MemberRole) => Promise + /** + * Hides the Role field for resources without per-member roles (skills): + * every add is a plain grant and `addMember` receives the default role. + */ + hideRole?: boolean +} + +/** + * Shared "Add people" modal for member-managed resources (credentials, skills): + * grants existing workspace members access, optionally with a chosen role. + * Emails are validated against the workspace roster and current membership; + * each add is an idempotent upsert and partial failures keep only the people + * that still need adding. + */ +export function AddPeopleModal({ + open, + onOpenChange, + existingMemberEmails, + addMember, + hideRole = false, +}: AddPeopleModalProps) { + const { workspacePermissions } = useWorkspacePermissionsContext() + + const [emailsToAdd, setEmailsToAdd] = useState([]) + const [roleToAdd, setRoleToAdd] = useState('member') + const [isAdding, setIsAdding] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const workspaceUserIdByEmail = useMemo( + () => + new Map( + (workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId]) + ), + [workspacePermissions?.users] + ) + + const validateAddEmail = useCallback( + (email: string): string | null => { + const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) + return 'error' in result ? result.error : null + }, + [workspaceUserIdByEmail, existingMemberEmails] + ) + + const handleClose = useCallback(() => { + setEmailsToAdd([]) + setRoleToAdd('member') + setSubmitError(null) + onOpenChange(false) + }, [onOpenChange]) + + const handleAddPeople = useCallback(async () => { + if (emailsToAdd.length === 0 || isAdding) return + setSubmitError(null) + const targets = emailsToAdd + .map((email) => { + const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) + return 'userId' in result ? { email, userId: result.userId } : null + }) + .filter((target): target is AddPeopleTarget => target !== null) + if (targets.length === 0) return + + setIsAdding(true) + try { + const results = await Promise.allSettled( + targets.map((target) => addMember(target, roleToAdd)) + ) + const failures = partitionSettledFailures(targets, results) + if (failures.length === 0) { + handleClose() + return + } + setEmailsToAdd(failures.map((target) => target.email)) + const firstError = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + logger.error('Failed to add some members', firstError?.reason) + const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.') + setSubmitError( + failures.length === targets.length + ? `Couldn't add people. ${reason}` + : `Couldn't add ${failures.length} of ${targets.length} people. ${reason}` + ) + } finally { + setIsAdding(false) + } + }, [ + emailsToAdd, + isAdding, + workspaceUserIdByEmail, + existingMemberEmails, + roleToAdd, + addMember, + handleClose, + ]) + + return ( + { + if (!next) handleClose() + }} + srTitle='Add people' + > + Add people + + + {!hideRole && ( + setRoleToAdd(role as MemberRole)} + disabled={isAdding} + /> + )} + {submitError} + + + + ) +} diff --git a/apps/sim/components/permissions/index.ts b/apps/sim/components/permissions/index.ts index 69aff65d947..6fd107d31c1 100644 --- a/apps/sim/components/permissions/index.ts +++ b/apps/sim/components/permissions/index.ts @@ -1,3 +1,12 @@ +export { AddPeopleModal, type AddPeopleTarget } from './add-people-modal' +export { + MEMBER_ROLE_OPTIONS, + type MemberRole, + type MemberRoleOption, + SKILL_EDITOR_ROLE_OPTIONS, + type SkillEditorRole, +} from './member-role-options' +export { MemberRow, type MemberRowMember } from './member-row' export { type OrgRole, OrgRoleSelector, @@ -8,6 +17,7 @@ export { type CredentialRoleSource, credentialRoleLockReason, RoleLockTooltip, + skillEditorLockReason, type WorkspaceRoleSource, workspaceRoleLockReason, } from './role-lock' diff --git a/apps/sim/components/permissions/member-role-options.ts b/apps/sim/components/permissions/member-role-options.ts new file mode 100644 index 00000000000..321f91149e3 --- /dev/null +++ b/apps/sim/components/permissions/member-role-options.ts @@ -0,0 +1,28 @@ +/** Role assignable to a member of a shared resource (credential, skill). */ +export type MemberRole = 'member' | 'admin' + +export interface MemberRoleOption { + value: MemberRole + label: string +} + +/** + * Roles assignable to a resource member. Shared by every member-management + * surface (credential detail, skill members) so role choices never drift. + */ +export const MEMBER_ROLE_OPTIONS: readonly MemberRoleOption[] = [ + { value: 'member', label: 'Member' }, + { value: 'admin', label: 'Admin' }, +] as const + +export type SkillEditorRole = 'editor' + +/** + * Skill membership is binary — a user either edits the skill or does not, and + * `skill_member` has no role column. The single option keeps the roster's role + * control identical in shape to the credential surface's while being honest + * that there is nothing to switch between; every skill row renders it disabled. + */ +export const SKILL_EDITOR_ROLE_OPTIONS: readonly { value: SkillEditorRole; label: string }[] = [ + { value: 'editor', label: 'Editor' }, +] as const diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx new file mode 100644 index 00000000000..8a235584204 --- /dev/null +++ b/apps/sim/components/permissions/member-row.tsx @@ -0,0 +1,90 @@ +'use client' + +import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn' +import { getUserColor } from '@/lib/workspaces/colors' +import type { MemberRole } from './member-role-options' +import { RoleLockTooltip } from './role-lock' + +export interface MemberRowMember { + userId: string + userName: string | null + userEmail: string | null + role: TRole +} + +interface MemberRowProps { + member: MemberRowMember + /** Why the role is fixed (derived access); null when editable. */ + lockReason: string | null + /** Whether the viewer can act on rows (shows the Remove column). */ + canManage: boolean + roleDisabled: boolean + removeDisabled: boolean + /** + * Role choices for the dropdown. A surface whose membership is binary + * (skills) passes its own single option and always sets `roleDisabled`. + */ + roleOptions: readonly { value: TRole; label: string }[] + /** Omitted on surfaces with nothing to switch between (the role stays disabled). */ + onRoleChange?: (role: TRole) => void + onRemove: () => void +} + +/** + * One member row of a shared-resource member list: avatar + identity, a role + * dropdown (wrapped in a lock tooltip when the role is derived), and a remove + * action for managers. Consumers own the policy (who is locked/disabled); this + * row owns the chrome. + */ +export function MemberRow({ + member, + lockReason, + canManage, + roleDisabled, + removeDisabled, + roleOptions, + onRoleChange, + onRemove, +}: MemberRowProps) { + return ( +
+
+ + + {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} + + +
+ + {member.userName || member.userEmail || member.userId} + + + {member.userEmail || member.userId} + +
+
+ + onRoleChange?.(role as TRole)} + /> + + {canManage && ( + + Remove + + )} +
+ ) +} diff --git a/apps/sim/components/permissions/role-lock.tsx b/apps/sim/components/permissions/role-lock.tsx index 6988b1200c4..db384516930 100644 --- a/apps/sim/components/permissions/role-lock.tsx +++ b/apps/sim/components/permissions/role-lock.tsx @@ -31,6 +31,15 @@ export function credentialRoleLockReason( return null } +/** + * Explanation shown when a skill editor's access is inherited from their + * workspace admin role rather than an explicit grant, and so cannot be removed. + * Returns null for explicitly added editors. + */ +export function skillEditorLockReason(isWorkspaceAdmin: boolean): string | null { + return isWorkspaceAdmin ? 'Workspace admins are automatically skill editors' : null +} + interface RoleLockTooltipProps { reason: string | null children: ReactNode diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 3693f88fd48..15cd1c21ed4 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -42,6 +42,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'sso', + 'domains', 'sessions', 'data-retention', 'data-drains', @@ -64,6 +65,7 @@ describe('settings navigation boundaries', () => { 'access-control', 'audit-logs', 'sso', + 'domains', 'sessions', 'data-retention', 'data-drains', @@ -119,6 +121,7 @@ describe('settings navigation boundaries', () => { 'billing', 'data-drains', 'data-retention', + 'domains', 'organization', 'sessions', 'sso', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index acf5ac6c8b6..c8eab37aea1 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -6,6 +6,7 @@ import { HexSimple, Key, KeySquare, + Link, Lock, LogIn, Palette, @@ -42,6 +43,7 @@ export type OrganizationSettingsSection = | 'access-control' | 'audit-logs' | 'sso' + | 'domains' | 'sessions' | 'data-retention' | 'data-drains' @@ -88,6 +90,7 @@ export type UnifiedSettingsSection = | 'teammates' | 'organization' | 'sso' + | 'domains' | 'whitelabeling' | 'copilot' | 'forks' @@ -541,6 +544,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] organization: { id: 'sso', group: 'security', order: 4 }, }, }, + { + label: 'Verified domains', + icon: Link, + docsLink: 'https://docs.sim.ai/platform/enterprise/verified-domains', + unified: { + id: 'domains', + description: 'Prove ownership of your email domains before configuring SSO.', + group: 'enterprise', + requiresHosted: true, + requiresEnterprise: true, + selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + }, + planes: { + organization: { id: 'domains', group: 'security', order: 5 }, + }, + }, { label: 'Session policies', icon: Clock, @@ -554,7 +573,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, }, planes: { - organization: { id: 'sessions', group: 'security', order: 5 }, + organization: { id: 'sessions', group: 'security', order: 6 }, }, }, { @@ -571,7 +590,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, }, planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 6 }, + organization: { id: 'data-retention', group: 'enterprise', order: 7 }, }, }, { @@ -587,7 +606,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, }, planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 7 }, + organization: { id: 'data-drains', group: 'enterprise', order: 8 }, }, }, { @@ -603,7 +622,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, + organization: { id: 'whitelabeling', group: 'enterprise', order: 9 }, }, }, { @@ -739,6 +758,7 @@ export function getOrganizationSettingsFeatures( 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + domains: SETTINGS_SELF_HOSTED_OVERRIDES.sso, sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx index 66ae7a1efc3..d2a3e0d6324 100644 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ b/apps/sim/components/settings/organization-settings-renderer.tsx @@ -23,6 +23,9 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) +const DomainSettings = dynamic(() => + import('@/ee/sso/components/domain-settings').then((module) => module.DomainSettings) +) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (module) => module.SessionPolicySettings @@ -68,6 +71,9 @@ export function OrganizationSettingsRenderer({ } if (section === 'audit-logs') return if (section === 'sso') return + if (section === 'domains') { + return + } if (section === 'sessions') { return } diff --git a/apps/sim/components/ui/shimmer-text.module.css b/apps/sim/components/ui/shimmer-text.module.css index 53637471b1a..f5a727623e4 100644 --- a/apps/sim/components/ui/shimmer-text.module.css +++ b/apps/sim/components/ui/shimmer-text.module.css @@ -13,7 +13,7 @@ -webkit-background-clip: text; background-clip: text; color: transparent; - animation: shimmer-sweep 2.2s linear infinite reverse; + animation: shimmer-sweep 2.2s linear infinite; } :global(.dark) .shimmer { @@ -21,8 +21,11 @@ } @keyframes shimmer-sweep { + from { + background-position: 100% 0; + } to { - background-position: 200% 0; + background-position: -100% 0; } } diff --git a/apps/sim/components/ui/shimmer-text.tsx b/apps/sim/components/ui/shimmer-text.tsx index 6e3f7fea8f6..56f2b37662c 100644 --- a/apps/sim/components/ui/shimmer-text.tsx +++ b/apps/sim/components/ui/shimmer-text.tsx @@ -1,10 +1,12 @@ +import type { ComponentPropsWithoutRef, ElementType } from 'react' import { cn } from '@sim/emcn' import styles from '@/components/ui/shimmer-text.module.css' -interface ShimmerTextProps { +type ShimmerTextProps = { + as?: T children: React.ReactNode className?: string -} +} & Omit, 'as' | 'children' | 'className'> /** * Sweeping-highlight shimmer over a text phrase — the same treatment as the @@ -12,6 +14,16 @@ interface ShimmerTextProps { * Size and weight come from the consumer's className; the gradient replaces * the text color, so color classes are ignored while shimmering. */ -export function ShimmerText({ children, className }: ShimmerTextProps) { - return {children} +export function ShimmerText({ + as, + children, + className, + ...props +}: ShimmerTextProps) { + const Comp = as ?? 'span' + return ( + + {children} + + ) } diff --git a/apps/sim/content/library/ai-agent-ideas/index.mdx b/apps/sim/content/library/ai-agent-ideas/index.mdx index beb40e84f50..c565963bbb8 100644 --- a/apps/sim/content/library/ai-agent-ideas/index.mdx +++ b/apps/sim/content/library/ai-agent-ideas/index.mdx @@ -3,7 +3,7 @@ slug: ai-agent-ideas title: '10 AI Agent Ideas for Real Impact: Get Started With Sim' description: Explore practical AI agent ideas you can build today to automate real workflows. From email triage to lead enrichment, discover use cases that deliver fast, measurable impact. date: 2026-06-30 -updated: 2026-06-30 +updated: 2026-07-23 authors: - emir readingTime: 14 @@ -212,3 +212,5 @@ The gap between "AI agents sound useful" and "we have an agent running in produc The teams seeing the best results in 2026 aren't building grand autonomous systems. They're prioritizing task-specific, governed AI agents that integrate with real business systems rather than broad autonomous experimentation. They're starting narrow, proving value, and expanding. Pick the idea that matches your team's biggest pain point. Open Sim, build the workflow, and deploy your first agent today. You'll learn more in that first hour of building than in another month of reading about what's possible. + +Not sure an agent is the right shape for your problem? [AI agent vs chatbot](/library/ai-agent-vs-chatbot) draws the line, and [AI agents vs RPA](/library/ai-agents-vs-rpa) covers where rule-based automation still wins. When you're ready to build, [how to build AI agents](/library/how-to-create-an-ai-agent) is the step-by-step, and [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) compares where to build it. diff --git a/apps/sim/content/library/ai-agent-observability/index.mdx b/apps/sim/content/library/ai-agent-observability/index.mdx new file mode 100644 index 00000000000..6d49debe728 --- /dev/null +++ b/apps/sim/content/library/ai-agent-observability/index.mdx @@ -0,0 +1,134 @@ +--- +slug: ai-agent-observability +title: 'AI Agent Observability: Why It Is Essential' +description: AI agent observability gives step-by-step visibility into how agents reason, call tools, and decide, so you can trace failures, control costs, and ship with confidence. +date: 2026-07-19 +updated: 2026-07-19 +authors: + - andrew +readingTime: 9 +tags: [AI Agent Observability, Observability, AI Agents, Monitoring, Sim] +ogImage: /library/ai-agent-observability/cover.jpg +ogAlt: AI agent observability turning an agent from a black box into an inspectable glass box. +canonical: https://www.sim.ai/library/ai-agent-observability +draft: false +faq: + - q: "What is AI agent observability?" + a: "AI agent observability is the practice of capturing and analyzing an agent's internal behavior to understand and improve how it works. It rests on four pillars: traces (the full task path), logs (step-level events), metrics (latency, cost, and error rates), and evaluations (output quality scoring)." + - q: "How is AI agent observability different from traditional monitoring?" + a: "Traditional monitoring answers 'is the system up?' by tracking uptime, response times, and status codes. Agent observability answers 'is the agent making good decisions?' by inspecting reasoning and tool choices. It exists because agents are non-deterministic, so the same prompt can produce different behavior each run." + - q: "What is the difference between traces and spans?" + a: "A trace is the complete path of a single task from start to finish. Spans are the individual steps within that trace, such as one LLM call or one tool invocation. Together they form a span tree that shows how the whole task unfolded." + - q: "When does AI agent observability become critical?" + a: "In prototyping, it is optional, since print statements and instant reruns are enough. It becomes essential in production, where you need full execution context to reproduce reported failures. It becomes even more important in multi-agent systems, where failures happen between agents and across turns." + - q: "What metrics should I track for AI agents?" + a: "Track latency per task and step, cost per run and per model, request and tool-call error rates, and success rates by task type. Add agent-specific signals like tool-selection accuracy and hallucination detection, since these predict reliability in ways generic metrics cannot." + - q: "Do I need a separate observability tool?" + a: "Dedicated observability tools exist and work well, especially for large, multi-framework deployments. But if you build in a workspace with native logging, you can cover core needs like execution logs, trace spans, and per-model cost tracking without setting up a separate stack. Match the choice to your scale and existing tooling." + - q: "Does observability help control agent costs?" + a: "Yes. By attributing token usage, latency, and cost to individual steps, observability shows exactly which prompts, tools, or loops drive spend. That lets you catch expensive patterns during testing, before they compound across production traffic." +--- + +Your agent aced every question in the demo. In production, it confidently returns a wrong answer, calls the wrong tool, or loops on itself, and the dashboard stays green the whole time. You know something broke, but you have no way to see where or why. + +AI agent observability helps you fix this issue. It exposes how an agent reasons, which tools it calls, what it retrieves, and where it goes off track, so debugging becomes an evidence-based process instead of guesswork. + +This guide covers what observability is, why traditional monitoring falls short for agents, what to instrument at each stage, the signals worth tracking, and how to start. + +## Key Takeaways + +- **Observability makes agents inspectable:** It captures reasoning steps, tool calls, retrievals, and outputs so you can understand and improve agent behavior. +- **Observability rests on four key pillars:** Traces, logs, metrics, and evaluations together turn a black box into a glass box. +- **Traditional monitoring doesn't give you the visibility you need:** APM confirms the system is up, not whether the agent made good decisions on non-deterministic runs. +- **Flying blind is expensive:** Untraced failures erode trust, hide cost leaks, and create compliance gaps as agents act autonomously. +- **Instrumentation scales with lifecycle:** Print statements suffice in prototyping; full execution context is non-negotiable in production. +- **Open standards reduce lock-in:** [OpenTelemetry's GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) standardize what to capture across frameworks and vendors. + +## What Is AI Agent Observability? + +AI agent observability is the practice of capturing and analyzing an agent's internal behavior – its reasoning steps, tool calls, retrievals, and outputs – to understand and improve how it works. It answers not just whether the agent ran, but what it decided and why. + +Four building blocks make this possible. Traces record the full path of a task from start to finish. Logs capture detailed events at each step. Metrics measure latency, token usage, cost, and error or success rates. Evaluations judge whether outputs are accurate, relevant, and safe. + +Together, they turn the agent from a black box into a glass box you can inspect, debug, and improve as you observe how it works. + +| Pillar | What It Captures | Example Data Point | Why It Matters | +| --- | --- | --- | --- | +| Traces | The full path of a single task | Span tree from user request through tool calls | Reproduces exactly how a task unfolded | +| Logs | Detailed events at each step | Prompt version sent to the model | Pinpoints the moment behavior changed | +| Metrics | Quantitative performance signals | Tokens and cost per run | Surfaces expensive or slow patterns | +| Evaluations | Output quality scoring | Faithfulness or relevance score | Confirms whether the answer was any good | + +## Why Traditional Monitoring Falls Short for Agents + +Traditional application performance monitoring was built for deterministic software. It tracks uptime, response times, CPU, memory, and HTTP status codes, all reliable proxies for health when the same input always produces the same output. + +Agents break that assumption. The same prompt can trigger different tool sequences, retrieve different documents, and produce different answers on each run, so a green dashboard tells you nothing about decision quality. As [Fiddler's analysis of OpenTelemetry](https://www.fiddler.ai/blog/opentelemetry-ai-observability-guide) puts it, telemetry captures what happened, but it does not assess whether what happened was good. + +Monitoring agent output requires a shift in mindset. You're not just asking "Is the system healthy?" You also need to know whether the agent reasoned soundly and chose the right tools. Establishing this requires data that legacy tools cannot collect: prompts, reasoning chains, tool invocations, context retrieval, and multi-agent handoffs. + +## Why Observability Is Essential: The Risks of Flying Blind + +Running agents without visibility exposes you to significant risk in four important areas. + +The business impact comes first. Incorrect responses erode revenue and customer trust, and you cannot fix a root cause you cannot trace. In [LangChain's State of AI Agents report](https://www.langchain.com/stateofaiagents), quality remains the biggest barrier to production. This year, one-third of respondents cited quality as their primary blocker. + +Operationally, hallucinations, hallucinated tool calls, decision loops, and drift degrade performance, and each failure compounds across multi-step systems. On compliance, missing audit trails and weak explainability create regulatory exposure, especially in regulated industries where agents act autonomously on sensitive data. On cost, spend that looked affordable in a pilot leaks unchecked at scale without visibility into token usage and tool-invocation patterns. + +These risks scale with adoption. [PwC's AI agent survey](https://www.pwc.com/us/en/tech-effect/ai-analytics/ai-agent-survey.html) found that 79 percent say AI agents are already being adopted in their companies – the more organizations that adopt AI, the greater your potential liability. + +## What to Instrument and When + +Instrumentation needs to scale with the stage of the agent's lifecycle. Match your effort to where you are instead of over-building early or under-building late. + +### Prototyping + +Print statements and local logs are usually enough when you run one execution at a time and can rerun instantly. Focus on watching tool calls and outputs while you iterate quickly. This is where edge cases such as ambiguous queries, retrieval failures, and tool timeouts first surface. + +### Pre-Production + +Move to structured traces that capture tool calls, prompt versions, and model outputs so you can compare behavior across test runs. Start building evaluation datasets from real runs, so your tests reflect real-world behavior rather than idealized inputs. + +### Production + +You need full execution context, including conversation history, retrieval results, and reasoning, to reproduce reported failures. Track per-step token usage, latency, and cost to catch expensive patterns before they hit the budget, then feed production traces back into regression tests and evaluations to drive continuous improvement. + +| Stage | Primary Goal | What to Capture | Tooling Approach | +| --- | --- | --- | --- | +| Prototyping | Iterate fast on behavior | Tool calls, outputs, edge cases | Print statements and local logs | +| Pre-Production | Compare runs reliably | Structured traces, prompt versions, eval sets | Structured tracing and eval datasets | +| Production | Reproduce and improve | Full execution context, per-step cost and latency | Continuous tracing plus regression evals | + +## Core Metrics and Signals to Track + +Focus on tracking metrics that indicate how reliably agents perform. Start with the fundamentals: latency per task and per step, cost per run and per model, request and tool-call error rates, and success rates broken out by task type. + +Then, add the agent-specific signals traditional tools miss: + +- **Tool selection accuracy:** Did the agent choose the right tool for the step? +- **Reasoning-path quality:** Was the decision chain sound, or did it wander? +- **Context window utilization:** How much of the window is doing useful work? +- **Hallucination detection:** Did the output contradict retrieved sources? + +Evaluations score the qualitative side. LLM-as-a-judge and code-based evals grade correctness, relevance, and tool-usage accuracy. In multi-agent systems, session-level and thread-level visibility matters more than isolated single traces, because failures happen between agents and across turns. + +Retrieval-heavy agents add their own failure surface, which we cover in [the best AI agents for data extraction and RAG](/library/best-ai-agents-for-data-extraction-and-rag-in-2026). If your agents call external tools over the Model Context Protocol, [what an MCP server is](/library/what-is-an-mcp-server) explains the tool boundary you'll be tracing across. + +## How to Get Started With AI Agent Observability + +Here are some practical first steps you can take immediately: + +- **Emit telemetry from day one.** Instrument agents to produce traces, logs, and metrics before you need them, not after an incident. +- **Adopt open standards.** Use [OpenTelemetry's GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/), which standardize how GenAI operations are recorded. This avoids vendor lock-in. +- **Trace at the decision layer.** Capture reasoning and tool choices, not just request-response boundaries. +- **Close the loop.** Build test datasets from real production traces and run continuous evaluations. + +Plan for common challenges too: trace volume at scale, alert fatigue, fragmented visibility across systems, and privacy or PII handling in telemetry. + +Building in a workspace with native logging removes much of the complexity of this process. When you manage observability from the environment where you build and deploy agents, you get execution logs, trace spans, and per-model cost tracking without assembling a separate stack. Sim's Logs module works this way, giving full workflow logs, trace spans, and cost breakdowns per model and token type inside the visual workflow builder itself. If you are still assembling that workflow, [how to build AI agents with Sim](/library/how-to-create-an-ai-agent) walks through the first one. + +## The Bottom Line + +If your agents touch production, treat observability as a launch requirement, not a later add-on, because you cannot debug, cost-control, or trust what you cannot see. The fastest way to start is to instrument at the decision layer today and route those traces somewhere you can query them. + +[Create your next agent in a workspace with built-in observability](https://sim.ai), so execution logs, trace spans, and per-model cost tracking come standard from your very first run. diff --git a/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx b/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx index 963e71ffd55..4311e723e7b 100644 --- a/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx +++ b/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx @@ -3,7 +3,7 @@ slug: ai-agent-vs-chatbot title: 'AI Agent vs Chatbot: Understanding the Differences' description: Understand the key differences between AI agents vs chatbots, from architecture to real-world use cases. Learn when to use each and how to choose the right approach for your workflows. date: 2026-06-28 -updated: 2026-06-28 +updated: 2026-07-23 authors: - emir readingTime: 13 @@ -212,3 +212,5 @@ If your workflow requires multi-step reasoning, cross-system coordination, or ac AI agents are moving from experimental to expected across enterprise teams, and the adoption curve is steep. For your team, the question isn't whether to bring agents in; it's where to start. Pick one workflow that's currently breaking down: reports that take hours to compile manually, and employee onboarding sequences that require five people to coordinate. Build an agent for that, prove ROI, then expand from there. + +For the adjacent comparison, [AI agents vs RPA](/library/ai-agents-vs-rpa) covers rule-based automation rather than conversational tools. If you've decided an agent is what you need, [10 AI agent ideas](/library/ai-agent-ideas) has starting points, [how to build AI agents](/library/how-to-create-an-ai-agent) walks through the first one, and [the best AI agents for customer support automation](/library/best-ai-agents-for-customer-support-automation) goes deep on the support use case specifically. diff --git a/apps/sim/content/library/ai-agents-in-procurement/index.mdx b/apps/sim/content/library/ai-agents-in-procurement/index.mdx new file mode 100644 index 00000000000..e1538081ba9 --- /dev/null +++ b/apps/sim/content/library/ai-agents-in-procurement/index.mdx @@ -0,0 +1,123 @@ +--- +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-07-21 +authors: + - andrew +readingTime: 8 +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. +canonical: https://www.sim.ai/library/ai-agents-in-procurement +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." + - 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." + - q: "What procurement tasks can AI agents automate first?" + a: "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." + - q: "Will AI agents replace procurement jobs?" + a: "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." + - q: "Do I need to code to build a procurement agent?" + a: "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." + - q: "How do I keep procurement agents secure and compliant?" + a: "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 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. + +## 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, 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. + +## What Are AI Agents in Procurement? + +AI agents in procurement use a large language model (LLM) to interpret a goal, break it into steps, and act across your systems with limited human supervision. Agentic AI is the broader layer above that: multiple agents coordinating toward complex, multi-stage goals, like running a full sourcing event end to end. + +Agents work in a simple loop. They perceive by monitoring spend, supplier data, and inbound requests. They reason by weighing tradeoffs against policy and thresholds. Then they act, executing or recommending a decision within set guardrails. + +Under the hood, agents combine several building blocks: + +- LLMs for language understanding +- Orchestration logic to sequence tasks +- Memory for context, tools, and API integrations to reach your systems +- Retrieval-augmented generation to ground answers in your real data +- Human-in-the-loop controls for approvals + +### AI Agents vs Traditional Procurement Software + +Legacy procurement tools automate specific tasks using static, predefined rules and lean heavily on human oversight. RPA (robotic process automation) bots automate workflows with clearly defined rules, inputs, outputs, and process triggers. AI agents adapt, interpret messy inputs, and make context-based decisions across multiple steps. We cover this distinction in depth in [AI agents vs RPA](/library/ai-agents-vs-rpa). + +| Approach | Adaptability | Human Oversight Needed | Best For | +| --- | --- | --- | --- | +| Traditional procurement software | Low, fixed rules | High, manual steps and review | Structured forms, catalogs, approvals | +| RPA bots | Low, breaks on change | Medium, exception handling | Repetitive, high-volume data entry | +| AI agents | High, reasons over context | Low to medium, approvals on key calls | Judgment-heavy, multi-step work | + +Rules-based tools remain a solid fit for stable, high-volume steps. Agents provide the most value on judgment-heavy, multi-step work where inputs vary. + +## Where AI Agents Deliver Value in Procurement + +The fastest wins come where there's abundant unstructured data and repeatable knowledge work a human can review. Four areas stand out. + +**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) 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. + +**Contract lifecycle and renewals.** Agents surface key terms, flag anomalies, monitor compliance, and prompt renewals before deadlines slip. + +**Purchase orders, supplier management, and risk.** Agents automate PO creation, watch supplier performance and external risk signals, and escalate issues to a person. Throughout, humans manage strategy, relationships, and final approvals while agents clear the repetitive load. + +## Should You Buy a Pre-Built Agent or Build Your Own? + +Buying makes sense when you have a narrow, standardized need and a mature vendor already serves it. Building may have the edge if your workflows are unique, you run multiple existing systems, or you have strict data control requirements. + +| Criteria | Pre-Built Suite | Build in a Workspace | +| --- | --- | --- | +| Fit to your process | Vendor's template | Shaped to your workflows | +| Integration with existing tools | Limited to the suite | Broad, connects your stack | +| Speed to first agent | Fast if it fits | Fast with templates | +| Customization | Constrained | Full control | +| 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. It connects 1,000+ integrations including Salesforce, Slack, Gmail, databases, and ERP systems, without adopting a rigid suite. + +For regulated procurement, it also fits governance needs: 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](/library/best-ai-agent-platforms-2026) compares the field. + +## How to Get Started With Procurement Agents + +Start with one narrow, low-risk agent rather than a full transformation. A strong first candidate is supplier email triage, an agent that scans inbound messages, flags delays, price increases, or contract issues, and logs each one to your system. + +Break the process down into smaller tasks: + +- **Pick a repeatable task:** Choose something high-volume with clear inputs. +- **Confirm data and systems:** Identify the sources and tools the agent needs. +- **Define goals and thresholds:** Set what "good" looks like and when to escalate. +- **Add guardrails and approvals:** Keep a human on decisions that touch spend. +- **Measure, then expand:** Track time saved, cycle time, and spend under management before rolling out more. + +Data readiness and guardrails are the two most common failure points, so address both before scaling. Sim's pre-built templates for email triage, data enrichment, and feedback analysis give teams a fast starting point they can customize and deploy quickly. For a step-by-step first build, see [how to build AI agents with Sim](/library/how-to-create-an-ai-agent). + +## Challenges and Best Practices + +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) found that more than half of organizations (53%) do not have their key procurement data integrated into a single system or architecture. 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. + +Agents should clear repetitive work while procurement professionals shift toward orchestration, oversight, and category strategy. Avoid seeing AI agents as a direct replacement for human procurement individuals, but hold them to the same security expectations. If they can act on spend or take other actions a human worker could, access control, audit trails, and data residency are non-negotiable. + +## The Bottom Line + +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. + +You can [build that first agent in Sim](https://sim.ai) from a template today, then expand once the results are on the table. diff --git a/apps/sim/content/library/ai-agents-vs-rpa/index.mdx b/apps/sim/content/library/ai-agents-vs-rpa/index.mdx index 2c28017abce..2df9ca1ae51 100644 --- a/apps/sim/content/library/ai-agents-vs-rpa/index.mdx +++ b/apps/sim/content/library/ai-agents-vs-rpa/index.mdx @@ -3,7 +3,7 @@ slug: ai-agents-vs-rpa title: 'AI Agents vs RPA: When to Use Each for Enterprise Automation' description: Understand the key differences between AI agents vs RPA, from rule-based automation to intelligent decision-making. Learn when to use each and how to combine both for scalable workflows. date: 2026-06-29 -updated: 2026-06-29 +updated: 2026-07-23 authors: - emir readingTime: 13 @@ -145,7 +145,7 @@ Use AI agents when: No. And framing the question that way misses the market reality entirely. -The global RPA market was estimated at $4.68 billion in 2025 and is projected to grow at a CAGR of 29% through 2033, according to Grand View Research. That's not a dying market. RPA is growing alongside AI agent adoption because the two technologies solve different problems. Every enterprise has structured, high-volume, rule-based processes that RPA handles very well. +The global RPA market was estimated at $4.68 billion in 2025 and is projected to reach $35.84 billion by 2033, a CAGR of 29.0%, [according to Grand View Research](https://www.grandviewresearch.com/industry-analysis/robotic-process-automation-rpa-market). That's not a dying market. RPA is growing alongside AI agent adoption because the two technologies solve different problems. Every enterprise has structured, high-volume, rule-based processes that RPA handles very well. ## When to Combine Both: The Hybrid Automation Architecture @@ -222,3 +222,5 @@ This is also when governance frameworks need to mature: The AI agents vs RPA question isn't really a versus at all. RPA gives you consistent, auditable execution on structured tasks and legacy systems. AI agents give you reasoning, adaptability, and the ability to handle the messy, variable work that RPA was never designed for. Trying to solve every automation problem with just one of these tools means you're either over-engineering simple tasks or leaving complex processes stuck in manual mode. The practical path forward: audit where your current RPA bots hand off to humans, deploy AI agents at those specific seams, and build the integration layer that lets both technologies work as a single system. Start small, prove the hybrid model on one or two high-value workflows, and scale from there. + +If you're at the stage of picking a platform for the agent half of that stack, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) compares the options. For the distinction one level down, [AI agent vs chatbot](/library/ai-agent-vs-chatbot) covers where conversational tools stop and agents begin, and [how to build AI agents](/library/how-to-create-an-ai-agent) walks through a first build. diff --git a/apps/sim/content/library/apache-2-0-vs-fair-code/index.mdx b/apps/sim/content/library/apache-2-0-vs-fair-code/index.mdx index 47f7f82b777..43d355d00db 100644 --- a/apps/sim/content/library/apache-2-0-vs-fair-code/index.mdx +++ b/apps/sim/content/library/apache-2-0-vs-fair-code/index.mdx @@ -3,7 +3,7 @@ slug: apache-2-0-vs-fair-code title: "Apache 2.0 vs Fair-Code: Why Sim's License Beats n8n's for Self-Hosting" description: Apache 2.0 vs n8n's fair-code Sustainable Use License - what OSI open source actually means, what each license permits for self-hosting, embedding, and resale, and how Sim, n8n, Dify, and Zapier compare. date: 2026-07-14 -updated: 2026-07-14 +updated: 2026-07-23 authors: - andrew readingTime: 9 @@ -39,19 +39,19 @@ If "open source" is part of your decision, the license is the detail that decide ## What "open source" actually means -The term has a formal definition maintained by the Open Source Initiative. To qualify, a license must allow free use, modification, and redistribution with no restriction on the field of use, commercial use included. Apache 2.0, MIT, GPL, and MPL all clear that bar. Under any of them you can run the software for any purpose, including building a competing product, and no one can revoke that right later. +The term has a [formal definition](https://opensource.org/osd) maintained by the Open Source Initiative. To qualify, a license must allow free use, modification, and redistribution with no restriction on the field of use, commercial use included. Apache 2.0, MIT, GPL, and MPL all clear that bar. Under any of them you can run the software for any purpose, including building a competing product, and no one can revoke that right later. "Source available" is a different thing. You can read the code and often modify it, but the license attaches conditions an OSI-approved license would not permit. The code sits on GitHub, which feels open, but the legal rights are narrower than the label suggests. ## Fair-code and the Sustainable Use License -"Fair-code" is a term n8n popularized. It is not an OSI category. n8n's core ships under the Sustainable Use License, which grants broad rights for internal business use and self-hosting but restricts using the software to offer a competing hosted service or to redistribute it as a commercial product. +"Fair-code" is a term n8n popularized. It is not an OSI category. n8n's core ships under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) ([full text in the repo](https://github.com/n8n-io/n8n/blob/master/LICENSE.md)), which grants broad rights for internal business use and self-hosting but restricts using the software to offer a competing hosted service or to redistribute it as a commercial product. That model is legitimate and widely adopted. n8n uses it to stop cloud providers from wrapping the open code and reselling it at scale, which is a real commercial risk permissive licenses do nothing about. Calling fair-code a lesser license misreads it. It solves a different problem than Apache 2.0 does, and for a team automating its own operations, the internal-use grant covers everything they need. The distinction only turns decisive when your plans cross the line the license draws. ## What Apache 2.0 unlocks that fair-code restricts -Apache 2.0 permits four things the Sustainable Use License holds back, and each maps to a concrete plan. +[Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) permits four things the Sustainable Use License holds back, and each maps to a concrete plan. **Run it as a multi-tenant service.** Spin up one Sim deployment, put separate customer workspaces on it, charge for access, and Apache 2.0 permits that with no commercial conversation. Hosting the product as a paid multi-tenant service for other people is the exact use a fair-code license carves out. @@ -129,3 +129,5 @@ The right license depends on what you plan to do with the software, not on which - **Automating internal deterministic workflows and never reselling or multi-tenanting?** n8n's Sustainable Use License permits exactly that, and its community edition is the right starting point. For the Sim path, [start with `npx simstudio`](https://sim.ai) to run locally, then move to Docker or Kubernetes for production. For internal-automation-only, n8n's community edition covers the job without cost or friction. + +If licensing is what pushed you to look elsewhere, [10 best n8n alternatives](/library/n8n-alternatives) and [open-source AI agent platforms](/library/open-source-ai-agent-platforms) both compare the field with license terms called out explicitly. diff --git a/apps/sim/content/library/best-ai-agent-platforms-2026/index.mdx b/apps/sim/content/library/best-ai-agent-platforms-2026/index.mdx index 88fbe3750ea..f733367310c 100644 --- a/apps/sim/content/library/best-ai-agent-platforms-2026/index.mdx +++ b/apps/sim/content/library/best-ai-agent-platforms-2026/index.mdx @@ -3,7 +3,7 @@ slug: best-ai-agent-platforms-2026 title: "Best AI Agent Platforms in 2026: A Comparison of 11 Tools" description: A comparison of eleven AI agent platforms - Sim, n8n, Zapier, Make, Gumloop, Vellum, MindStudio, Dust, Kore.ai, Rasa, and Lindy - scored against deployment model, license, observability, multi-LLM flexibility, and agent lifecycle control. date: 2026-07-16 -updated: 2026-07-16 +updated: 2026-07-23 authors: - andrew readingTime: 10 @@ -69,7 +69,7 @@ The honest limitation: that number is well behind Zapier's 8,000+ and Make's 1,0 ## n8n -n8n calls itself a fair-code platform, and that word matters more than the "open" label most people assume. It ships under the Sustainable Use License and a separate Enterprise License, not Apache 2.0. You can read the source, self-host it, and write custom nodes, but the license restricts commercial resale and reserves some features for paid tiers. +[n8n](https://n8n.io/pricing/) calls itself a fair-code platform, and that word matters more than the "open" label most people assume. It ships under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) and a separate Enterprise License, not Apache 2.0. You can read the source, self-host it, and write custom nodes, but the license restricts commercial resale and reserves some features for paid tiers. The self-hosting story is strong. You start with `npx n8n` or a Docker image, reach the editor at `localhost:5678`, and connect to more than 1,500 integrations plus an HTTP node for any other API. Model choice stays open across OpenAI, Anthropic, Google, and self-hosted options like Ollama, and switching providers doesn't force you to rebuild a workflow. @@ -81,7 +81,7 @@ n8n is the most mature self-hosted option in this comparison and the community a ## Zapier -Zapier turned its Zaps automation product into AI task execution through Zapier Agents, currently in open beta, and the whole thing runs on prompt configuration alone. There is no agent SDK and no programmatic way to define an agent, so what you can build stops at what the UI form accepts. The Platform CLI exists only for building app integrations, not agents. +[Zapier](https://zapier.com/pricing) turned its Zaps automation product into AI task execution through Zapier Agents, currently in open beta, and the whole thing runs on prompt configuration alone. There is no agent SDK and no programmatic way to define an agent, so what you can build stops at what the UI form accepts. The Platform CLI exists only for building app integrations, not agents. The integration breadth is genuinely hard to match, and for many teams it's the only criterion that matters. Zapier connects to 8,000+ apps and exposes 30,000+ actions, with native connectors to Box, Dropbox, Google Drive, and Notion that pull live data into an agent's context. If your bottleneck is reaching data spread across dozens of SaaS tools, few platforms beat it. @@ -91,7 +91,7 @@ That breadth sits on top of thin agent controls. You cannot chat with a Zapier A ## Make -Make.com launched its AI Agents capability in April 2025, and the design choice shows in what it does well and what it skips. Make built agents into its existing scenario builder, so an agent runs as another step inside a workflow rather than as a standalone runtime. That approach suits teams already automating processes across Make's 1,000+ app integrations, and it keeps everything inside one visual builder, which is one of the better ones in this category. +[Make.com](https://www.make.com/en/pricing) launched its AI Agents capability in April 2025, and the design choice shows in what it does well and what it skips. Make built agents into its existing scenario builder, so an agent runs as another step inside a workflow rather than as a standalone runtime. That approach suits teams already automating processes across Make's 1,000+ app integrations, and it keeps everything inside one visual builder, which is one of the better ones in this category. The gaps appear when you compare Make against platforms built for agents first. A third-party feature comparison marks Make.com as lacking memory and context handling, meaning agents do not retain state across interactions. The same table shows no hosted dev or production environments and no explainability features. You get detailed execution logs for troubleshooting, but not a versioned staging-to-production path. @@ -101,7 +101,7 @@ Make also deploys agents on a schedule rather than exposing them as an API, a ch ## Gumloop -Gumloop builds for business teams that want to skip the engineering queue. Its clearest strength is native Microsoft Teams deployment. Agents live inside Teams channels, respond to @mentions, pull data, generate reports, and run multi-step actions from plain-language prompts. For an operations lead or support manager who already runs the day inside Teams, that removes the usual gap between a request and an automated response. +[Gumloop](https://www.gumloop.com/pricing) builds for business teams that want to skip the engineering queue. Its clearest strength is native Microsoft Teams deployment. Agents live inside Teams channels, respond to @mentions, pull data, generate reports, and run multi-step actions from plain-language prompts. For an operations lead or support manager who already runs the day inside Teams, that removes the usual gap between a request and an automated response. The enterprise controls back this up. Gumloop offers role-based access, single sign-on, and audit logging, which are the boxes IT needs checked before a non-technical team touches customer or finance data. @@ -160,3 +160,5 @@ Zapier and Make win on integration breadth, so choose them when you already run If deployment monitoring and command over running agents are what decide the purchase, Sim is built for that specific problem, and Logs, Chat, and Tables handle lifecycle work you'd otherwise stitch together yourself. [Start building on Sim](https://sim.ai) or [self-host from the repo](https://github.com/simstudioai/sim). + +Narrowing by a specific constraint? [Open-source AI agent platforms](/library/open-source-ai-agent-platforms) filters to self-hostable options, [LangGraph alternatives](/library/langgraph-alternatives) covers the code-first category, [10 best n8n alternatives](/library/n8n-alternatives) and [best Zapier alternatives](/library/best-zapier-alternatives) approach the same market from the automation-tool side. diff --git a/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx b/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx index 90be869f995..1c104c17fbb 100644 --- a/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx +++ b/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx @@ -75,7 +75,7 @@ Whether you choose draft-and-approve or full autonomy separates most buyers. A d ## n8n for technical teams building custom support workflows -n8n is the pick for technical teams that want node-based control over every branch of a support workflow. Its execution engine has matured over years of production use, and its node library covers hundreds of services with the granular parameter control that engineers expect. When you need a support automation with custom error handling, conditional retries, and precise data transformations between a helpdesk and a CRM, n8n gives you the primitives to build exactly what you want. +[n8n](https://n8n.io/pricing/) is the pick for technical teams that want node-based control over every branch of a support workflow. Its execution engine has matured over years of production use, and its node library covers hundreds of services with the granular parameter control that engineers expect. When you need a support automation with custom error handling, conditional retries, and precise data transformations between a helpdesk and a CRM, n8n gives you the primitives to build exactly what you want. The template ecosystem shortens the path from blank canvas to working flow. You can pull a community workflow for ticket enrichment or Slack escalation, then rewire it to your stack rather than starting from scratch. For a team comfortable reading and editing node graphs, that flexibility pays off across every automation you build after the first. @@ -85,7 +85,7 @@ The main concession is the build curve. n8n provides native nodes for assembling ## Zapier for teams that want the largest app catalog -Zapier wins on catalog breadth, and that alone explains why so many support teams default to it. With more than 9,000 app connections, Zapier almost certainly already talks to the tools your support stack runs, whether that means Zendesk, Salesforce, Slack, or a survey tool nobody else supports. When your goal is wiring one event to another action across a sprawling SaaS stack, Zapier's coverage means you rarely hit a dead end where an integration simply doesn't exist. +[Zapier](https://zapier.com/pricing) wins on catalog breadth, and that alone explains why so many support teams default to it. With more than 9,000 app connections, Zapier almost certainly already talks to the tools your support stack runs, whether that means Zendesk, Salesforce, Slack, or a survey tool nobody else supports. When your goal is wiring one event to another action across a sprawling SaaS stack, Zapier's coverage means you rarely hit a dead end where an integration simply doesn't exist. That breadth pairs with a build model most ops people can pick up in an afternoon. Zapier's trigger-and-action Zaps read like plain sentences, and a support-ops lead with no coding background can ship a working automation the same day. For teams already standardized on Zapier across marketing and sales ops, adding a few support automations costs almost nothing in learning curve. @@ -105,7 +105,7 @@ That difference defines who Make fits. If your support automation is mostly dete ## Gumloop for ops teams automating support workflows with templates -Gumloop earns its place for ops-led teams that measure success in weeks, not months. Its template library ships with pre-built support workflows you can clone and adjust, so an operations lead can stand up a triage-to-routing flow without hiring an engineer or learning a node graph from scratch. For support-ops buyers specifically, that head start matters, because most of them own the ticketing process but not the codebase. +[Gumloop](https://www.gumloop.com/pricing) earns its place for ops-led teams that measure success in weeks, not months. Its template library ships with pre-built support workflows you can clone and adjust, so an operations lead can stand up a triage-to-routing flow without hiring an engineer or learning a node graph from scratch. For support-ops buyers specifically, that head start matters, because most of them own the ticketing process but not the codebase. The template approach shapes the whole product. Gumloop's UX assumes you want to configure existing patterns rather than design new ones, and it rewards that assumption with clean workflows and quick wins for common tasks like feedback intake, tagging, and handoffs to a helpdesk. If your team already knows the shape of the automation you need and just wants it running, Gumloop gets you there faster than tools that make you design from a blank canvas. @@ -143,3 +143,5 @@ Ops-led teams optimizing existing workflows should start with Zapier or Gumloop. Enterprise teams that need governance and scale should weigh Sim's self-hosting against their own compliance requirements. Running the workspace inside your own infrastructure keeps customer conversations and Knowledge Base contents on hardware you control, and deploying the same workflow as an API, hosted chat interface, or MCP tool lets one governed agent serve multiple support surfaces without duplicate builds. Start where the friction is lowest. Open a hosted account at [sim.ai](https://sim.ai) to start building a triage agent, or self-host through Docker if your policy requires it. Gumloop's template library is the fastest route if you want a working support flow before you commit to a full build. + +Related reading: [AI agent vs chatbot](/library/ai-agent-vs-chatbot) explains why a support agent is a different thing from a support chatbot, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) compares the platforms behind these builds, and [how to build AI agents](/library/how-to-create-an-ai-agent) is the general walkthrough. 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 7b636a89723..dc0c47f0125 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 @@ -3,7 +3,7 @@ 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 document extraction, SQL queries, spreadsheet analysis, and RAG over internal documents in 2026. See how Sim, n8n, Zapier, Make, and Gumloop handle data workflows. date: 2026-07-01 -updated: 2026-07-01 +updated: 2026-07-23 authors: - andrew readingTime: 19 @@ -59,11 +59,11 @@ Yes, AI agents can turn plain-English questions into SQL, but the quality depend Mothership extends the same grounding to the build step. It holds context across every table in the workspace, so a request like "create a CRM table, seed it with my existing leads, and schedule a daily sync" produces the table, the rows, and the workflow in one pass. No competitor on this list ships an equivalent. -n8n exposes native database nodes for Postgres, MySQL, and others, and you can pair them with an AI node that drafts SQL. The catch is that you assemble the schema-passing step yourself, often by querying the information schema and piping it into the prompt. n8n gives you full control, but a non-technical user still needs to understand SQL well enough to debug what the model generates. +[n8n](https://n8n.io/pricing/) exposes native database nodes for Postgres, MySQL, and others, and you can pair them with an AI node that drafts SQL. The catch is that you assemble the schema-passing step yourself, often by querying the information schema and piping it into the prompt. n8n gives you full control, but a non-technical user still needs to understand SQL well enough to debug what the model generates. -Zapier and Make both connect to databases, yet neither treats natural-language querying as a first-class feature. In Zapier, you typically trigger on a row or run a pre-written query, and the AI steps summarize results rather than compose SQL against a live schema. Make lets you build the query flow visually with granular control over each database call, but a non-technical user hits a wall the moment the logic needs a hand-written WHERE clause or a JOIN the visual builder does not template. +[Zapier](https://zapier.com/pricing) and [Make](https://www.make.com/en/pricing) both connect to databases, yet neither treats natural-language querying as a first-class feature. In Zapier, you typically trigger on a row or run a pre-written query, and the AI steps summarize results rather than compose SQL against a live schema. Make lets you build the query flow visually with granular control over each database call, but a non-technical user hits a wall the moment the logic needs a hand-written WHERE clause or a JOIN the visual builder does not template. -Gumloop leans on AI-specific nodes and can generate queries as part of a data flow, though it still expects you to wire the database connection and supply schema context for reliable output. It stays closer to plain English than Zapier or Make, but you are still stitching retrieval logic together. +[Gumloop](https://www.gumloop.com/pricing) leans on AI-specific nodes and can generate queries as part of a data flow, though it still expects you to wire the database connection and supply schema context for reliable output. It stays closer to plain English than Zapier or Make, but you are still stitching retrieval logic together. The practical divide is where the plain-English experience ends. Sim keeps it end to end because Tables carry the schema the agent needs. The integration platforms get you a working query, and they push the schema-grounding and SQL debugging back onto whoever built the flow. @@ -115,7 +115,7 @@ Pick n8n when you want to run the whole thing on your own infrastructure and you The second reason is the Code node. n8n lets you drop JavaScript or Python into any step, which means you can assemble a RAG pipeline exactly the way you want it by wiring a vector database, an embedding call, and a retrieval query together by hand. You give up the native Knowledge Base that Sim provides, but you gain full control over chunking, indexing, and which model touches your data. -The third reason is momentum. If your team has already built dozens of n8n workflows and your operations run through them, adding light document extraction or a database query to an existing flow costs less than migrating to an agent-native platform. Stay with n8n when self-hosting, custom code, and prior investment matter more than having retrieval built in. See the [full OpenAI AgentKit vs n8n vs Sim comparison](https://www.sim.ai/library/openai-vs-n8n-vs-sim) for a deeper breakdown. +The third reason is momentum. If your team has already built dozens of n8n workflows and your operations run through them, adding light document extraction or a database query to an existing flow costs less than migrating to an agent-native platform. Stay with n8n when self-hosting, custom code, and prior investment matter more than having retrieval built in. See the [full OpenAI AgentKit vs n8n vs Sim comparison](/library/openai-vs-n8n-vs-sim) for a deeper breakdown. ## Choose Zapier when @@ -148,3 +148,5 @@ Sim's Knowledge Bases handle semantic retrieval with connector sync across 50+ s Sim is Apache 2.0 licensed and self-hostable through Docker or Kubernetes, and the cloud platform is [SOC 2 compliant](https://www.sim.ai/blog/enterprise), so regulated teams can keep documents inside their own infrastructure without giving up the native primitives. Pick Sim when you'd otherwise spend more time assembling a vector database, a parsing service, and a data store than building the actual agent logic. Teams shipping RAG-heavy agents feel this most, since every external dependency adds a failure point and a sync problem to debug. If retrieval and extraction are the product, start at [Sim](https://sim.ai) and add integrations only where its native pieces fall short. + +For the wider field, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) ranks platforms on general agent capability, [open-source AI agent platforms](/library/open-source-ai-agent-platforms) covers the self-hostable subset, and [how to build AI agents](/library/how-to-create-an-ai-agent) walks through assembling a first workflow. diff --git a/apps/sim/content/library/best-ai-agents-sales-crm-automation/index.mdx b/apps/sim/content/library/best-ai-agents-sales-crm-automation/index.mdx index d3b4345dae6..59ae045b9e0 100644 --- a/apps/sim/content/library/best-ai-agents-sales-crm-automation/index.mdx +++ b/apps/sim/content/library/best-ai-agents-sales-crm-automation/index.mdx @@ -3,7 +3,7 @@ slug: best-ai-agents-sales-crm-automation title: 'Best AI Agents for Sales and CRM Automation' description: Compare the best AI agents for sales and CRM automation - Sim, n8n, Zapier, Make, and Gumloop - across lead qualification, CRM write-back, enrichment, and outbound sequencing to pick the right tool for your RevOps stack. date: 2026-07-20 -updated: 2026-07-20 +updated: 2026-07-23 authors: - andrew readingTime: 13 @@ -45,7 +45,7 @@ The platforms that update CRM records well are the ones with native write-back i The record update is only as good as the logic that runs before the write. A raw enrichment payload usually arrives with inconsistent casing, duplicate company entries, and free-text job titles that no CRM field expects. In Sim, that cleanup happens inside the agentic loop rather than as a bolted-on step. An Agent block normalizes the fields, checks a native Table or the CRM itself for an existing record, and merges instead of creating a duplicate. The write only fires once the data matches the shape your CRM expects. -Zapier deserves credit for the breadth of its app catalog. If you need to touch a niche CRM or a long-tail sales tool, Zapier almost certainly has a prebuilt connector, and that saves real setup time. The tradeoff is depth. Zapier's connectors tend to cover the common actions, so complex field logic, conditional updates, and dedupe rules push you into workarounds or multiple stacked Zaps. +[Zapier](https://zapier.com/pricing) deserves credit for the breadth of its app catalog. If you need to touch a niche CRM or a long-tail sales tool, Zapier almost certainly has a prebuilt connector, and that saves real setup time. The tradeoff is depth. Zapier's connectors tend to cover the common actions, so complex field logic, conditional updates, and dedupe rules push you into workarounds or multiple stacked Zaps. For teams running Salesforce or HubSpot as their system of record, Sim's combination of native write-back and in-workflow normalization does more with fewer moving parts. You keep the scoring, the dedupe check, and the record update in one place, which means one thing to debug when a field lands wrong. @@ -67,7 +67,7 @@ Model choice compounds the effect. Sim supports BYOK across 15+ model providers, ### The honest concession -Clay and Apollo own proprietary datasets and waterfall access that Sim does not claim to replace as a data source. Apollo maintains a large people-and-company database, and Clay's waterfall enrichment queries multiple providers in sequence to fill gaps, both backed by data relationships and coverage Sim doesn't reproduce. If your bottleneck is raw coverage on hard-to-find contacts, those tools earn their place, and Sim can call them as one of the enrichment APIs inside the loop. +[Clay](https://www.clay.com/pricing) and [Apollo](https://www.apollo.io/pricing) own proprietary datasets and waterfall access that Sim does not claim to replace as a data source. Apollo maintains a large people-and-company database, and Clay's waterfall enrichment queries multiple providers in sequence to fill gaps, both backed by data relationships and coverage Sim doesn't reproduce. If your bottleneck is raw coverage on hard-to-find contacts, those tools earn their place, and Sim can call them as one of the enrichment APIs inside the loop. The distinction worth holding onto is between the data layer and the orchestration layer. Clay and Apollo win the data layer, and that's a genuine advantage for teams whose whole problem is finding records. Sim wins the orchestration, logic, and CRM write-back layer, which is where most RevOps teams actually lose time. You stop paying a separate vendor to run scoring and CRM sync you could own, and you keep the freedom to plug any data provider into a workflow you control. For teams evaluating a Clay alternative, the question is rarely who has more data. It's whether you want to rent the workflow around enrichment or own it. @@ -79,7 +79,7 @@ The personalization depends on where the context lives. Sim keeps account and le Template-only sequencing tools skip that loop. They fire the next step on a timer or an open event, and the copy stays static regardless of what the prospect actually did. That works for volume, but it caps how relevant any single message can get, since the tool never reasons over the context behind the send. -Gumloop deserves credit here. Its packaged outbound and GTM templates give you a working sequence on day one, which is a real advantage if you want to prospect this week rather than build a flow first. If your outbound motion fits one of those templates, you will move faster starting there than starting from a blank workflow in Sim's builder. The tradeoff shows up later, when your sequencing logic diverges from the packaged shape and you need the open-ended control that Sim's agent-and-Table model gives you. +[Gumloop](https://www.gumloop.com/pricing) deserves credit here. Its packaged outbound and GTM templates give you a working sequence on day one, which is a real advantage if you want to prospect this week rather than build a flow first. If your outbound motion fits one of those templates, you will move faster starting there than starting from a blank workflow in Sim's builder. The tradeoff shows up later, when your sequencing logic diverges from the packaged shape and you need the open-ended control that Sim's agent-and-Table model gives you. ### Gumloop's GTM and RevOps templates @@ -114,3 +114,5 @@ When your enrichment logic, scoring rules, and CRM write-back live inside a vend Model choice is the second half of ownership. Most enrichment tools bolt you to one fixed scoring engine, and you take whatever quality and cost that engine ships. Sim supports bring-your-own-key across 15+ model providers, so you route qualification through whichever model fits the job and pay the provider directly instead of a marked-up per-enrichment fee. You can swap a cheaper model for high-volume triage and a stronger one for accounts worth deeper analysis. The practical payoff is escaping point-solution sprawl. A typical RevOps stack runs Clay for waterfall enrichment, Apollo for prospecting data, and a separate automation tool to move records into the CRM, with each vendor billing separately and each handoff creating a place for data to break. Sim collapses the orchestration, logic, and write-back into one workflow layer while you still call whichever data providers you want as tools inside it. You keep the proprietary datasets from vendors who genuinely own the data, and you stop paying three companies to own the glue between them. Owning that layer means your qualification logic and CRM mappings travel with you, not with a vendor's roadmap. + +Related reading: [10 AI agent ideas](/library/ai-agent-ideas) covers use cases beyond RevOps, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) compares where to build them, and [best Zapier alternatives](/library/best-zapier-alternatives) is the right starting point if your CRM automation currently runs on per-task billing. diff --git a/apps/sim/content/library/best-relay-app-alternatives-2026/index.mdx b/apps/sim/content/library/best-relay-app-alternatives-2026/index.mdx index 7acd8083c41..f8c1a5ba72b 100644 --- a/apps/sim/content/library/best-relay-app-alternatives-2026/index.mdx +++ b/apps/sim/content/library/best-relay-app-alternatives-2026/index.mdx @@ -3,7 +3,7 @@ slug: best-relay-app-alternatives-2026 title: 'Best Relay.app Alternatives in 2026' description: Relay.app is shutting down in 2026. Compare the best Relay.app alternatives - Sim, n8n, Zapier, Make, and Gumloop - with license, self-host, and migration-effort breakdowns to switch before your deadline. date: 2026-07-17 -updated: 2026-07-17 +updated: 2026-07-23 authors: - andrew readingTime: 12 @@ -26,7 +26,7 @@ faq: ## TL;DR -Relay.app announced its shutdown on July 16, 2026, which is why this list exists. Free accounts and all their data get permanently deleted after August 15, 2026 at 23:59 PT. Paying customers keep full access free through September 14, 2026 at 23:59 PT. +[Relay.app announced its shutdown](https://www.relay.app/) on July 16, 2026, which is why this list exists. Free accounts and all their data get permanently deleted after August 15, 2026 at 23:59 PT. Paying customers keep full access free through September 14, 2026 at 23:59 PT. - **Top pick: Sim.** Built AI-native, Apache 2.0 licensed, self-hostable, with a free tier that isn't feature-limited. - **n8n** wins for complex multi-step integrations with deep branching and a large node ecosystem. @@ -125,3 +125,5 @@ Read the license and self-host columns together if you handle regulated or priva ## How we evaluated these Relay.app alternatives We ranked these five tools against the criteria that decide how fast a Relay user can actually switch: license, self-host capability, AI-native design, migration effort, and pricing model. Speed to migrate carried the most weight because you have a hard deadline, not a research window. A tool that requires rebuilding every workflow from scratch fails you no matter how strong its feature list looks. License and self-host mattered next, since anyone burned by a hosted shutdown has a real reason to want code they control and data they can keep. + +If your shortlist crosses over into general automation, [best Zapier alternatives](/library/best-zapier-alternatives) and [10 best n8n alternatives](/library/n8n-alternatives) cover that field. [The best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) is the right list if agent capability matters more to you than human-in-the-loop approvals. diff --git a/apps/sim/content/library/best-zapier-alternatives/index.mdx b/apps/sim/content/library/best-zapier-alternatives/index.mdx index da1bb30a4d5..8c53e698fdf 100644 --- a/apps/sim/content/library/best-zapier-alternatives/index.mdx +++ b/apps/sim/content/library/best-zapier-alternatives/index.mdx @@ -3,7 +3,7 @@ slug: best-zapier-alternatives title: '10 Best Zapier Alternatives for Workflow Automation' description: Zapier's per-task pricing punishes growth. Compare the 10 best Zapier alternatives - free, open-source, and AI-native - with real pricing breakdowns and use-case recommendations. date: 2026-07-01 -updated: 2026-07-01 +updated: 2026-07-23 authors: - emir readingTime: 14 @@ -17,7 +17,7 @@ faq: - q: "Is n8n better than Zapier?" a: "For teams with developer resources, n8n wins on cost (free self-hosted with no execution limits) and data control (nothing leaves your infrastructure). Zapier wins on ease of use, integration breadth (7,000+ apps vs. n8n's smaller catalog), and polished onboarding. The best answer depends on whether your team has the expertise to manage a self-hosted instance. If you do, n8n can save thousands annually. If you don't, the time cost of maintaining n8n may exceed the subscription savings." - q: "What is the cheapest Zapier alternative?" - a: "For zero-cost automation, n8n and Activepieces self-hosted options are free with no execution limits. Among paid hosted platforms, Pabbly Connect starts at $16/month with annual billing and includes 12,000 tasks with no charges for internal steps. Pabbly also offers lifetime pricing from $249 one-time, making it the cheapest long-term option for teams that want hosted automation without managing their own servers." + a: "For zero-cost automation, n8n and Activepieces self-hosted options are free with no execution limits, and Activepieces cloud includes 10 free active flows before charging $5 per active flow per month. Among paid hosted platforms, Pabbly Connect's Standard plan covers 10,000 tasks per month with no charges for internal steps. Pabbly also offers lifetime pricing from $349 one-time for 3,000 tasks per month, making it the cheapest long-term option for teams that want hosted automation without managing their own servers." - q: "Can I migrate my Zaps to another platform?" a: "There is no automated migration tool between platforms. You need to rebuild workflows manually. Simple two- to three-step Zaps typically take 10 - 15 minutes to recreate on any alternative platform. Complex multi-branch workflows with conditional logic, custom formatting, and multiple API connections can take 30 - 60 minutes each. Most Zapier Zaps can be recreated in Make in 15 - 30 minutes because the concepts are similar, and a full migration typically takes one to two days for a moderate-sized automation library." - q: "What is the difference between Zapier and an AI agent workflow platform?" @@ -25,7 +25,7 @@ faq: - q: "Does Sim charge per task like Zapier does?" a: "No. Sim uses a credit-based model instead of per-task billing, and it supports bring-your-own API keys so you can avoid markup on model usage entirely. A free plan is available with no credit card required." - q: "Is there a Zapier alternative that supports human approval steps in an automation?" - a: "Relay.app is purpose-built for this — it pauses a workflow, notifies the right person, collects their input, and continues, rather than treating every step as fully automated. It's best suited to RevOps, HR, legal, and finance workflows where a judgment call needs a human in the loop before the automation proceeds." + a: "Relay.app was purpose-built for this, but it is shutting down: new signups closed on July 16, 2026, free accounts end August 15, 2026, and paid accounts end September 14, 2026. For new builds, use a platform with native human-in-the-loop approvals instead. Sim ships approval steps that pause a run, notify the right person, collect their decision, and resume, which covers the same RevOps, HR, legal, and finance workflows without a migration deadline." --- You're here because the invoice surprised you. @@ -42,9 +42,9 @@ Whether you're hitting the complexity ceiling, need self-hosting for compliance, - **n8n:** Self-host for free with no execution limits and full data control. Requires technical chops but can't be beat on cost or privacy. -- **Pabbly Connect:** Flat-rate pricing with a lifetime deal starting at $249 one-time. The budget pick for straightforward multi-step automations. +- **Pabbly Connect:** Flat-rate pricing with a [lifetime deal](https://buy.pabbly.com/connect-onetime/) starting at $349 one-time. The budget pick for straightforward multi-step automations. -- **Workato:** Enterprise-grade integration with strong governance, audit logging, and support SLAs. Priced accordingly, starting around $1,000/month. +- **Workato:** Enterprise-grade integration with strong governance, audit logging, and support SLAs. Priced accordingly — Workato [does not publish rates](https://www.workato.com/pricing) and quotes every deal through sales. - **Activepieces:** Open-source with an MIT license and a cleaner, more approachable UI than n8n. Growing integration library (750+) with a free self-hosted tier. @@ -52,7 +52,7 @@ Whether you're hitting the complexity ceiling, need self-hosting for compliance, - **Pipedream:** Hybrid code + no-code with serverless execution and 1,000+ integrations. Best for developers who want to drop real Node.js, Python, or Go into a workflow wherever a connector falls short. -- **Relay.app:** Purpose-built for human-in-the-loop workflows that pause for approval or review. Best for RevOps, HR, legal, and finance teams where one step needs a human decision. +- **Relay.app (shutting down):** Was purpose-built for human-in-the-loop approval workflows, but the product is winding down — free accounts end August 15, 2026 and paid accounts September 14, 2026. Listed here only because existing users need somewhere to go. - **Integrately:** One-click, pre-built automations with minimal configuration. Best for standard, well-defined scenarios where setup speed matters more than deep customization. @@ -86,7 +86,7 @@ A free plan is available with no credit card required, and paid plans follow a c Instead of a linear list of steps, Make gives you a visual canvas showing exactly how data flows through your automation. You can see branches, conditions, loops, and error handlers all at once, making debugging easier because you can literally trace where things went wrong. -The pricing model is where Make changes the math for growing teams. Make's Core plan starts at $10.59/month for 10,000 operations, compared to Zapier's $29.99/month for 750 tasks at entry-level pricing. Operations and tasks don't map 1:1; Make counts operations differently than Zapier counts tasks, and a single Zapier task might equal three to eight Make operations. Most teams running moderate automation volume will spend meaningfully less on Make. +The pricing model is where Make changes the math for growing teams. [Make's](https://www.make.com/en/pricing) Core plan starts at $12/month for 10,000 credits, compared to [Zapier's](https://zapier.com/pricing) $29.99/month for 750 tasks at entry-level pricing. Annual billing takes about 15% off Make's rate. Operations and tasks don't map 1:1; Make counts operations differently than Zapier counts tasks, and a single Zapier task might equal three to eight Make operations. Most teams running moderate automation volume will spend meaningfully less on Make. Make connects 3,000+ apps and offers deeper per-app API coverage than Zapier on many integrations, with native modules for routers, iterators, aggregators, and error handlers that let you build workflows Zapier simply can't express in its linear editor. @@ -100,7 +100,7 @@ n8n gives you a node-based visual editor with full JavaScript (and Python) suppo The data privacy angle is the primary reason teams choose n8n over hosted alternatives. Self-hosting means workflow data, API credentials, and execution logs never touch a third-party server. For companies in regulated industries or with strict data residency requirements, this isn't a nice-to-have; it's a hard requirement that rules out most other tools on this list. -n8n provides unlimited workflows and executions for free if you're willing to self-host. It requires a server and some DevOps knowledge, but there are no per-task charges at all. The cloud-managed version starts at roughly $24/month for teams that want n8n's workflow capabilities without managing infrastructure. +n8n provides unlimited workflows and executions for free if you're willing to self-host. It requires a server and some DevOps knowledge, but there are no per-task charges at all. The [cloud-managed version](https://n8n.io/pricing/) starts at €20/month billed annually for 2,500 workflow executions, for teams that want n8n's workflow capabilities without managing infrastructure. Note that n8n is fair-code licensed, not open source — we break down [what that means for self-hosting](/library/apache-2-0-vs-fair-code). This means you'll need considerable technical expertise in setup and ongoing maintenance, including updates, backups, SSL, and uptime monitoring, to save more. If your team doesn't have someone comfortable with Docker, server provisioning, and debugging node-level errors, n8n will cost you time instead of saving it. @@ -110,7 +110,7 @@ This means you'll need considerable technical expertise in setup and ongoing mai Pabbly Connect's value proposition is straightforward: flat-rate pricing with no per-task surcharges on internal steps. There is no charge for internal tasks like filters and routers, which means your actual task consumption is lower than the equivalent workflow would cost on Zapier. -Paid subscription plans include Standard at $16/month for 12,000 tasks, Pro at $33/month for 24,000 tasks, and Ultimate at $67/month for up to 300,000 tasks (billed annually). But the real differentiator is the lifetime deal: Pabbly Connect offers unlimited workflows with lifetime pricing from $249 one-time. That's a single payment for perpetual access with a fixed monthly task allocation. For solopreneurs and small businesses running stable, predictable automations, the long-term savings compared to any recurring-fee platform are substantial. +[Pabbly Connect's](https://www.pabbly.com/connect/) subscription lineup is a free tier at 100 tasks/month, a Standard plan at 10,000 tasks/month, and an Unlimited plan, with annual billing discounted against monthly. But the real differentiator is the [lifetime deal](https://buy.pabbly.com/connect-onetime/): a one-time payment from $349 for 3,000 tasks/month, $799 for 10,000, or $1,298 for 20,000. That's a single payment for perpetual access with a fixed monthly task allocation. For solopreneurs and small businesses running stable, predictable automations, the long-term savings compared to any recurring-fee platform are substantial. As of March 2026, Pabbly Connect supports over 2,000 application integrations and handles multi-step workflows, conditional logic, webhook triggers, and scheduled automation. However, the interface feels less polished than Zapier or Make, with occasional quirky UX decisions, and support is email-only on lower plans with response times that can stretch past 24 hours. @@ -134,13 +134,13 @@ Workato is where you land when "we need Zapier but for the enterprise" stops bei Enterprise-grade security features include robust audit logging, role-based access control, environment management (dev/staging/production), and support SLAs backed by contractual commitments. Workato connects deeply into ERP systems, CRMs, HRIS platforms, and custom internal applications, making it the standard choice for enterprises running mission-critical integrations across SAP, Salesforce, Workday, and NetSuite. -The pricing puts it firmly out of range for SMBs: expect starting costs around $1,000/month with pricing that scales based on connector count and recipe volume. This is negotiated, contract-based enterprise purchasing. +Pricing puts it firmly out of range for SMBs. [Workato publishes no rates at all](https://www.workato.com/pricing) — every deal is quoted through sales and scales on connector count and recipe volume. This is negotiated, contract-based enterprise purchasing, so budget for a procurement cycle rather than a credit card. **Best for:** Enterprises running mission-critical integrations across ERP, CRM, and custom systems where governance, compliance, and reliable support SLAs justify the premium cost. ### Activepieces: Best Open-Source Pick for Non-Technical Users -Activepieces occupies a useful niche: open-source automation that doesn't require DevOps expertise to get started. Activepieces is licensed under MIT, the self-hosted version is free, and the cloud tier offers a free plan for light usage. +Activepieces occupies a useful niche: open-source automation that doesn't require DevOps expertise to get started. [Activepieces](https://www.activepieces.com/pricing) is licensed under MIT, the self-hosted version is free, and the cloud tier includes 10 free active flows before charging $5 per active flow per month with unlimited runs. The interface is cleaner and more approachable than n8n's, which makes Activepieces the better open-source pick for teams that want the benefits of open source (data control, no vendor lock-in, community-driven development) without the technical overhead of managing a self-hosted n8n instance. @@ -158,15 +158,17 @@ The pricing includes a generous free tier for individual developers and usage-ba **Best for:** Developers who need broad integration support with the freedom to write custom logic in real code wherever a no-code connector doesn't exist. -### Relay.app: Best for Human-in-the-Loop Workflows +### Relay.app: Shutting Down in 2026 -Relay.app addresses a genuine gap that Zapier and most alternatives ignore: workflows where a human needs to approve, review, or decide before automation continues. +**Relay.app is winding down.** [New signups and free-to-paid upgrades closed on July 16, 2026](https://www.relay.app/), free accounts stop working on August 15, 2026, and paying customers lose access on September 14, 2026. Do not start a new build here. If you are an existing user, export your data before your deadline and pick a replacement now — we cover the options in [Relay.app alternatives](/library/best-relay-app-alternatives-2026). + +It is worth understanding what Relay.app did well, because that is the capability you need to replace. Relay.app addressed a genuine gap that Zapier and most alternatives ignore: workflows where a human needs to approve, review, or decide before automation continues. Full automation is inappropriate in many business contexts. Legal review, financial approvals, content sign-off, HR decisions; these all involve judgment calls that you don't want an automation engine making unilaterally. But fully manual handling is equally wasteful when 90% of the workflow is routine, and only one step requires human input. Relay.app lets you build the automated pipeline and insert human decision points exactly where they're needed. The workflow pauses, notifies the right person, collects their input, and continues. -The interface is clean, the setup is straightforward, and the concept is immediately understandable to non-technical stakeholders. The limitation is scope: Relay.app doesn't try to be a general-purpose automation powerhouse. It's purpose-built for approval and review workflows. +The interface was clean, the setup straightforward, and the concept immediately understandable to non-technical stakeholders. The limitation was scope: Relay.app never tried to be a general-purpose automation powerhouse. -**Best for:** RevOps, HR, legal, and finance teams where full automation is inappropriate but full manual handling is equally wasteful. +**What to do instead:** if approval steps are the reason you were looking at Relay.app, choose a platform where human-in-the-loop is a first-class feature rather than a bolt-on. Sim ships approval blocks that pause a run, route it to the right person, and resume on their decision. ### Integrately: Best for One-Click Setup @@ -189,37 +191,37 @@ Start with two questions: What specifically is failing for you in Zapier? And wh | I want AI agents that reason and decide, not just data routing | Sim | Native AI decision-making blocks, multi-LLM support, self-hostable | | I need self-hosting for data compliance | n8n or Sim | Both offer Docker/Kubernetes deployment with no data leaving your infrastructure | | I just want something cheaper with visual workflows | Make | Operations-based pricing costs 2 - 5x less than Zapier at equivalent volume | -| I want the absolute lowest cost, period | Pabbly Connect | Flat-rate pricing, lifetime deal from $249 one-time, no per-task surcharges on internal steps | +| I want the absolute lowest cost, period | Pabbly Connect | Flat-rate pricing, lifetime deal from $349 one-time, no per-task surcharges on internal steps | | I'm deep in the Microsoft ecosystem | Power Automate | Native M365/Azure/Dynamics integration, possibly already in your license | | I need enterprise governance and support SLAs | Workato | Built for mission-critical integration with audit logging and environment management | | I want open source, but I'm not deeply technical | Activepieces | MIT-licensed, cleaner UI than n8n, free self-hosted tier | | I need code + no-code in the same workflow | Pipedream | Write Node.js/Python/Go alongside no-code steps, serverless execution | -| I need human approval steps in my automation | Relay.app | Purpose-built for workflows that pause for human review or decision | +| I need human approval steps in my automation | Sim | Native approval steps that pause a run for human review, then resume (Relay.app, the former pick here, is shutting down in 2026) | | I want basic automation running in five minutes | Integrately | Pre-built one-click automations with minimal configuration | ## Zapier vs. Alternatives: Pricing Breakdown -This table shows pricing as of mid-2026. Verify all figures directly with vendors before committing to a purchase, as pricing changes frequently. +Every figure below was checked against the vendor's own pricing page on July 23, 2026, and each tool name links to the page it came from. Pricing changes often — click through and confirm before you commit to a purchase. | Tool | Pricing Model | Entry-Level Paid Plan | Tasks/Ops Included | Free Tier? | Self-Host Option? | | --- | --- | --- | --- | --- | --- | -| Zapier | Per-task | $29.99/mo (monthly) | 750 tasks | Yes (100 tasks/mo, 2-step only) | No | -| Sim | Credit-based | Varies by plan | Credit-based allocation | Yes (free plan, no CC required) | Yes (Docker/K8s) | -| Make | Per-operation (credit) | ~$10.59/mo (annual) | 10,000 operations | Yes (1,000 ops/mo) | No | -| n8n | Self-host free; cloud paid | ~$24/mo (cloud) | Unlimited (self-hosted) | Yes (self-hosted unlimited) | Yes | -| Pabbly Connect | Flat-rate | $16/mo (annual) | 12,000 tasks | Yes (100 tasks/mo) | No | -| Power Automate | Per-user/month | ~$15/user/mo | Varies by plan | Included in some M365 licenses | No (on-prem gateway available) | -| Workato | Custom/contract | ~$1,000/mo | Negotiated | No | No | -| Activepieces | Free self-hosted; cloud plans | Cloud free tier available | Varies | Yes (self-hosted unlimited) | Yes | -| Pipedream | Usage-based | Free tier generous | Varies by plan | Yes | No | -| Relay.app | Per-run | Varies | Varies by plan | Yes (limited) | No | -| Integrately | Task-based | Competitive entry price | Varies by tier | Yes (limited) | No | +| [Zapier](https://zapier.com/pricing) | Per-task | $29.99/mo (monthly), $19.99/mo (annual) | 750 tasks | Yes (100 tasks/mo, 2-step only) | No | +| [Sim](https://sim.ai) | Credit-based | Varies by plan | Credit-based allocation | Yes (free plan, no CC required) | Yes (Docker/K8s) | +| [Make](https://www.make.com/en/pricing) | Per-credit | $12/mo (monthly), ~15% off annual | 10,000 credits | Yes (1,000 credits/mo) | No | +| [n8n](https://n8n.io/pricing/) | Self-host free; cloud paid | €20/mo (annual) | 2,500 executions cloud; unlimited self-hosted | Yes (self-hosted unlimited) | Yes | +| [Pabbly Connect](https://www.pabbly.com/connect/) | Flat-rate + lifetime option | Standard tier; [lifetime from $349](https://buy.pabbly.com/connect-onetime/) | 10,000 tasks (Standard) | Yes (100 tasks/mo) | No | +| [Power Automate](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) | Per-user/month | $15/user/mo (paid yearly) | Varies by plan | Included in some M365 licenses | No (on-prem gateway available) | +| [Workato](https://www.workato.com/pricing) | Custom/contract | Not published — quote only | Negotiated | No | No | +| [Activepieces](https://www.activepieces.com/pricing) | Free self-hosted; cloud plans | $5/active flow/mo after 10 free | Unlimited runs | Yes (10 active flows) | Yes (MIT) | +| [Pipedream](https://pipedream.com/pricing) | Usage-based | Free tier generous | Varies by plan | Yes | No | +| [Relay.app](https://www.relay.app/) | Shutting down 2026 | Closed to new signups | n/a | No (signups closed) | No | +| [Integrately](https://integrately.com/pricing) | Task-based | Competitive entry price | Varies by tier | Yes (limited) | No | Key insights from this table: - Zapier's per-task model and operations-based models (Make) and flat-rate models (Pabbly Connect) behave very differently at scale - Zapier's per-step task counting model means costs escalate quickly as workflows grow in complexity -- A five-step workflow running 100 times per day costs 500 tasks daily on Zapier, while Make's Core plan covers 10,000 operations per month for $10.59, and Pabbly's flat rate doesn't penalize you for internal steps at all +- A five-step workflow running 100 times per day costs 500 tasks daily on Zapier, while Make's Core plan covers 10,000 credits per month for $12, and Pabbly's flat rate doesn't penalize you for internal steps at all - For high-volume teams, the annual difference can be thousands of dollars ## The Bottom Line @@ -227,3 +229,5 @@ Key insights from this table: The best Zapier alternatives in 2026 are split into three categories. If your primary goal is cost reduction, Make and Pabbly Connect deliver the most dramatic savings with different trade-offs: Make gives you visual complexity at lower per-operation pricing, while Pabbly's flat rate and lifetime deal make costs fully predictable. If your goal is data control and compliance, n8n (self-hosted), Activepieces, and Sim all offer self-hosting options that keep your data on your own infrastructure. And if your workflows have outgrown trigger-action logic entirely and you need AI agents that reason, decide, and act across your stack, Sim is the platform built from the ground up for that shift. No single tool replaces Zapier for every team. Start by identifying the specific constraint that brought you here, whether it's pricing, complexity, data control, or AI capabilities, and match it against the routing table above. Most teams that switch successfully do so because they understand what they actually need rather than chasing the tool with the longest feature list. + +If n8n is the alternative you're leaning toward, we compare it against its own field in [10 best n8n alternatives](/library/n8n-alternatives). If you're evaluating on AI capability rather than cost, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) covers the same ground from that angle, and [open-source AI agent platforms](/library/open-source-ai-agent-platforms) narrows it to self-hostable options. Teams who specifically need a human approval step should start with [Relay.app alternatives](/library/best-relay-app-alternatives-2026). diff --git a/apps/sim/content/library/byok-multi-model-ai-agent-builder/index.mdx b/apps/sim/content/library/byok-multi-model-ai-agent-builder/index.mdx index 4b299f5b265..a5193f0270a 100644 --- a/apps/sim/content/library/byok-multi-model-ai-agent-builder/index.mdx +++ b/apps/sim/content/library/byok-multi-model-ai-agent-builder/index.mdx @@ -3,7 +3,7 @@ slug: byok-multi-model-ai-agent-builder title: "BYOK Multi-Model AI Agent Builder: How Sim's Bring-Your-Own-Key Works" description: Sim is a multi-model AI agent builder with hosted, BYOK, and local execution across 100+ models. Learn how bring-your-own-key works, when to use each mode, and how model flexibility compares across platforms. date: 2026-07-18 -updated: 2026-07-18 +updated: 2026-07-23 authors: - andrew readingTime: 9 @@ -72,7 +72,7 @@ Model flexibility separates these platforms more than any single feature, so the | Billing rate | ~1.1x hosted, provider rate on BYOK, free local | Provider rate | Bundled into platform pricing or task credits | | Self-host compatibility | Ollama, vLLM, fully local | None | Varies (n8n self-hosts, most do not for AI) | -The automation platforms earn their reputation on breadth and ease. Zapier connects thousands of apps with almost no configuration, Make gives you a visual canvas that non-developers can follow, and n8n self-hosts its entire workflow engine. Gumloop wraps AI steps in a friendly builder that gets a prototype running fast. If your problem is stitching SaaS tools together, these platforms solve it well, and model choice is a secondary concern. +The automation platforms earn their reputation on breadth and ease. [Zapier](https://zapier.com/pricing) connects thousands of apps with almost no configuration, Make gives you a visual canvas that non-developers can follow, and n8n self-hosts its entire workflow engine. Gumloop wraps AI steps in a friendly builder that gets a prototype running fast. If your problem is stitching SaaS tools together, these platforms solve it well, and model choice is a secondary concern. OpenAI's own stack has the opposite strength. You get tight integration with frontier models and first-party tooling, which matters when you are building squarely on GPT and want the shortest path to production. @@ -97,3 +97,5 @@ If you handle regulated data or need agents to run without an outbound internet For a multi-team organization standardizing on one provider, BYOK also keeps billing and model governance in one account you already control. None of these three modes is the correct default. The right one follows from your call volume, how much control you need over billing and models, and where your data is allowed to travel. + +Related reading: [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) covers which platforms support bring-your-own-key at all, [open-source AI agent platforms](/library/open-source-ai-agent-platforms) is the self-hostable subset where you keep full control of model routing, and [how to build AI agents](/library/how-to-create-an-ai-agent) walks through a first build. diff --git a/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx b/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx index dad63c030d6..daaf7ebbb4b 100644 --- a/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx +++ b/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx @@ -3,7 +3,7 @@ slug: how-to-create-an-ai-agent title: 'How to Build AI Agents With Sim' description: Learn how to create an AI agent from scratch using a visual workspace. Connect tools and deploy in minutes. Build real-world agents that automate workflows with Sim. date: 2026-06-27 -updated: 2026-06-27 +updated: 2026-07-23 authors: - waleed readingTime: 13 @@ -71,11 +71,11 @@ If you've searched for how to build AI agents before landing here, you've probab ### Path one: code frameworks -Tutorials built around LangChain, CrewAI, or AutoGen assume you're comfortable in Python, can manage virtual environments, and are willing to spend time wiring together chains, prompts, memory stores, and tool adapters before anything runs. The control is real; you can customize every layer of behavior. But the time-to-first-result is measured in days or weeks, not minutes. Agent frameworks require learning and extensive boilerplate code, and existing visual interfaces abstract most of the customization required for complex agent workflows. +Tutorials built around [LangChain](https://www.langchain.com/langchain), [CrewAI](https://docs.crewai.com/), or [AutoGen](https://microsoft.github.io/autogen/stable/) assume you're comfortable in Python, can manage virtual environments, and are willing to spend time wiring together chains, prompts, memory stores, and tool adapters before anything runs. The control is real; you can customize every layer of behavior. But the time-to-first-result is measured in days or weeks, not minutes. Agent frameworks require learning and extensive boilerplate code, and existing visual interfaces abstract most of the customization required for complex agent workflows. ### Path two: generic automation -On the other side, platforms like Zapier and Make let you connect apps quickly. They're great for linear workflows: "when this happens, do that." But they weren't built for agentic reasoning. When your workflow needs to branch based on ambiguous input, call an LLM to decide which tool to use, or loop until a condition is met, these tools hit a ceiling fast. +On the other side, platforms like [Zapier](https://zapier.com/pricing) and [Make](https://www.make.com/en/pricing) let you connect apps quickly. They're great for linear workflows: "when this happens, do that." But they weren't built for agentic reasoning. When your workflow needs to branch based on ambiguous input, call an LLM to decide which tool to use, or loop until a condition is met, these tools hit a ceiling fast. ### The third path: visual AI workspaces @@ -200,3 +200,5 @@ Learning how to build AI agents doesn't require a computer science degree, a com The pattern is straightforward: define a narrow task, open a workflow, add an Agent block with the right LLM and a tight system prompt, connect two or three tools, set a trigger, and iterate using execution logs. Start with the narrowest possible version of the idea, get it working reliably, then expand from there. Trusted by over 100,000 builders at startups and Fortune 500 companies, Sim offers a free plan with no credit card required. Open it, build something, and see what an agent can do for your workflow before the week is out. + +Stuck on what to build? [10 AI agent ideas](/library/ai-agent-ideas) has concrete starting points. If you're still deciding whether an agent is the right tool, [AI agent vs chatbot](/library/ai-agent-vs-chatbot) and [AI agents vs RPA](/library/ai-agents-vs-rpa) draw those lines, and [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) compares where to build. diff --git a/apps/sim/content/library/langgraph-alternatives/index.mdx b/apps/sim/content/library/langgraph-alternatives/index.mdx index de93b905b3e..12ee467db45 100644 --- a/apps/sim/content/library/langgraph-alternatives/index.mdx +++ b/apps/sim/content/library/langgraph-alternatives/index.mdx @@ -3,7 +3,7 @@ slug: langgraph-alternatives title: 'Best LangGraph Alternatives for Scalable AI Agent Workflows' description: LangGraph struggles at scale with concurrency, debugging, and deployment gaps. Compare the best alternatives - CrewAI, AutoGen, Sim, and more - to find the right fit for your team. date: 2026-07-13 -updated: 2026-07-13 +updated: 2026-07-23 authors: - emir readingTime: 11 @@ -21,7 +21,7 @@ faq: - q: "Is LangGraph still worth learning in 2026?" a: "Yes, if you genuinely require stateful graph control with human-in-the-loop approvals and your industry demands auditable, deterministic execution paths. Financial services, healthcare, and legal tech teams building compliance-sensitive agent workflows will find real value in LangGraph's explicit state management model. It is not worth learning if your goal is fast production deployment for business workflow automation, since the engineering overhead of building deployment, collaboration, and integration infrastructure on top of LangGraph is significant, and workspace platforms now handle that layer natively." - q: "How does Sim compare to LangGraph for enterprise use?" - a: "Sim and LangGraph serve enterprise teams from opposite directions. Sim ships deployment infrastructure (cloud-hosted with automatic scaling or self-hosted via Docker/Kubernetes), SOC2 and HIPAA compliance, real-time team collaboration with permission controls, 1,000+ pre-built integrations, and per-model cost tracking as built-in features. LangGraph offers deep audit trails and deterministic control over individual state transitions, which matter for regulated industry workflows requiring explicit approval chains. If your compliance team needs to trace every state transition, LangGraph wins. If your team needs agents running in production next month with 20 integrations and five people collaborating, Sim closes that gap faster." + a: "Sim and LangGraph serve enterprise teams from opposite directions. Sim ships deployment infrastructure (cloud-hosted with automatic scaling or self-hosted via Docker/Kubernetes), SOC2 compliance, real-time team collaboration with permission controls, 1,000+ pre-built integrations, and per-model cost tracking as built-in features. LangGraph offers deep audit trails and deterministic control over individual state transitions, which matter for regulated industry workflows requiring explicit approval chains. If your compliance team needs to trace every state transition, LangGraph wins. If your team needs agents running in production next month with 20 integrations and five people collaborating, Sim closes that gap faster." --- Your LangGraph prototype works. Agents handle branching logic, state persists across turns, and the demo goes well. Then you try to ship it: deployment means building your own serving layer, debugging parallel execution traces turns into a guessing game, and wiring 50 integrations means 50 custom connectors. Getting it to run in production for your team, rather than just on your machine, is easier said than done. @@ -67,7 +67,7 @@ If you're comfortable in Python (or TypeScript), want in-depth control over agen ### CrewAI -CrewAI takes a role-based, task-oriented approach where agents are "crew members" with defined roles, goals, and backstories. Instead of wiring graph edges, you assemble a team: a researcher, a writer, a reviewer. Each agent knows its job and passes work to the next. +[CrewAI](https://docs.crewai.com/) takes a role-based, task-oriented approach where agents are "crew members" with defined roles, goals, and backstories. Instead of wiring graph edges, you assemble a team: a researcher, a writer, a reviewer. Each agent knows its job and passes work to the next. This is the fastest path from idea to working multi-agent prototype. The mental model maps cleanly to how people already think about delegation, which means less time reading docs and more time building. @@ -79,7 +79,7 @@ The trade-off is that CrewAI doesn't include built-in checkpointing for long-run ### AutoGen / AG2 -AutoGen introduced a conversation-first approach to multi-agent systems: agents collaborate through structured multi-turn dialogue rather than graph transitions. This is a natural fit for use cases where agents need to debate, review, and refine outputs: code generation, analysis, planning scenarios where iterating on quality matters more than speed. +[AutoGen](https://microsoft.github.io/autogen/stable/) introduced a conversation-first approach to multi-agent systems: agents collaborate through structured multi-turn dialogue rather than graph transitions. This is a natural fit for use cases where agents need to debate, review, and refine outputs: code generation, analysis, planning scenarios where iterating on quality matters more than speed. The ecosystem, however, is in a complicated place. A significant development occurred in late 2024 when the original creators, Chi Wang and Qingyun Wu, departed Microsoft to establish AG2 as a community-driven fork. In November, they settled on creating a new AG2 GitHub organization and a new repo, inheriting the PyPI autogen and pyautogen packages and the Discord channel. Meanwhile, in October 2025, Microsoft announced that AutoGen and Semantic Kernel are merging into a new unified "Microsoft Agent Framework," with AutoGen entering maintenance mode. Microsoft Agent Framework is now positioned as the enterprise-ready successor to AutoGen, with stable APIs and a commitment to long-term support. @@ -93,7 +93,7 @@ One cost consideration that often gets overlooked: each agent turn in a conversa ### Google ADK -Google introduced the Agent Development Kit (ADK) at Google Cloud NEXT 2025 as an open-source framework designed to simplify end-to-end development of agents and multi-agent systems. The architecture uses a hierarchical agent tree where a root agent delegates to sub-agents, and ADK is the same framework powering agents within Google products like Agentspace and the Customer Engagement Suite. +Google introduced the [Agent Development Kit (ADK)](https://adk.dev/) at Google Cloud NEXT 2025 as an open-source framework designed to simplify end-to-end development of agents and multi-agent systems. The architecture uses a hierarchical agent tree where a root agent delegates to sub-agents, and ADK is the same framework powering agents within Google products like Agentspace and the Customer Engagement Suite. What sets ADK apart from most alternatives is its breadth of language support and deployment integration. ADK is available in Python, TypeScript, Go, and Java, which means teams aren't locked into a single language stack. You can run agents locally or scale them globally using Runtime, Cloud Run, or Google Kubernetes Engine. @@ -105,7 +105,7 @@ The standout capability for teams running multi-framework environments: ADK supp ### OpenAI Agents SDK -The OpenAI Agents SDK is a lightweight Python framework focused on multi-agent workflows with built-in tracing and guardrails. Despite the OpenAI branding, it's provider-agnostic and compatible with a broad range of LLMs, which makes the name slightly misleading but the tool genuinely flexible. +The [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) is a lightweight Python framework focused on multi-agent workflows with built-in tracing and guardrails. Despite the OpenAI branding, it's provider-agnostic and compatible with a broad range of LLMs, which makes the name slightly misleading but the tool genuinely flexible. The SDK's strength is its simple learning curve. If your team is already calling OpenAI APIs, the mental model transfers directly. Agents are defined with simple Python classes, handoffs between agents are clean and explicit, and tracing is built into every run without extra configuration. There's no graph definition language to learn, no complex expression layer to parse. @@ -117,7 +117,7 @@ The handoff model deserves specific mention: when one agent completes its portio ### Mastra -Mastra is an open-source TypeScript framework for building AI-powered applications and agents. It was built by the team behind Gatsby, and it's gained serious production traction since launching. Mastra is trusted by engineering teams at Replit, SoftBank, PayPal, PLAID, and Marsh McLennan. Marsh McLennan deployed an agentic search tool built with the framework to 75,000 employees. +[Mastra](https://mastra.ai/docs) is an open-source TypeScript framework for building AI-powered applications and agents. It was built by the team behind Gatsby, and it's gained serious production traction since launching. Mastra is trusted by engineering teams at Replit, SoftBank, PayPal, PLAID, and Marsh McLennan. Marsh McLennan deployed an agentic search tool built with the framework to 75,000 employees. The framework comes with a graph-based workflow engine that orchestrates complex multi-step processes with explicit control over execution, plus Mastra Studio, an interactive environment for testing agents, tracing execution, and refining prompts before and after you ship. This local dev playground for visualizing workflow graphs before deployment is a meaningful productivity advantage over purely code-based frameworks. @@ -144,7 +144,7 @@ Sim closes the gaps that consume most engineering time, the same ones that LangG - **Real-time collaboration:** Multiple team members can build workflows simultaneously with live editing, commenting, and granular permission controls at the workspace and group level. - **Full observability:** Workflow logs with block-by-block execution traces and per-model cost tracking. You can see exactly what each step cost and where time was spent. -Sim's model-agnostic approach spans OpenAI, Claude, Gemini, Groq, Cerebras, Mistral, xAI, and local models via Ollama or vLLM, so teams aren't locked to a single provider. For regulated-industry readers, the platform is SOC2 and HIPAA compliant, with self-hosted deployment options for organizations that need complete data sovereignty. +Sim's model-agnostic approach spans OpenAI, Claude, Gemini, Groq, Cerebras, Mistral, xAI, and local models via Ollama or vLLM, so teams aren't locked to a single provider. For regulated-industry readers, the platform is SOC2 compliant, with self-hosted deployment options for organizations that need complete data sovereignty. Chat's build mode is where the workflow differs most from code-first approaches. Teams can describe what they want in natural language and have Sim propose and apply changes to the workflow builder directly. This is significantly more efficient than writing and maintaining graph definitions in code, and it brings non-engineering stakeholders into the building process without sacrificing technical depth for the developers on the team. @@ -152,7 +152,7 @@ Chat's build mode is where the workflow differs most from code-first approaches. ### Dify -Dify is an open-source LLM app development platform with a visual workflow builder. It's well-suited for teams that want low-code agent construction with RAG (retrieval-augmented generation) pipelines and a broad integration set without needing to think in graph abstractions. +[Dify](https://dify.ai/pricing) is an open-source LLM app development platform with a visual workflow builder. It's well-suited for teams that want low-code agent construction with RAG (retrieval-augmented generation) pipelines and a broad integration set without needing to think in graph abstractions. Relative to LangGraph, Dify offers a significantly lower engineering floor to get started. You drag blocks, connect them visually, and deploy. The trade-off is less fine-grained control over state transitions for complex branching logic. If your workflow needs deterministic, auditable paths through dozens of conditional branches, Dify's visual model can feel limiting. But for RAG-heavy applications and LLM-powered tools where the goal is getting something running and connected to data sources fast, it's one of the most accessible options available. @@ -190,3 +190,5 @@ LangGraph is a strong tool for a specific set of problems. If your team requires For Python teams that want a different architectural model but still want to own their stack, CrewAI, Google ADK, and the OpenAI Agents SDK each offer compelling trade-offs depending on your cloud provider and use case. TypeScript teams have Mastra. Teams invested in Azure should watch the Microsoft Agent Framework closely. For teams where the real bottleneck is getting agents into production, connected to business tools, and maintained by more than one person, a workspace platform like Sim removes the infrastructure burden so you can focus on the agent logic itself. You can [start building in Sim](https://sim.ai) and see how visual, conversational, and API-driven agent building compares to graph definitions in code. + +For wider context: [open-source AI agent platforms](/library/open-source-ai-agent-platforms) covers the self-hostable field including the code-first frameworks, and [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) adds the commercial options. [How to build AI agents](/library/how-to-create-an-ai-agent) is the visual-first walkthrough if you're moving off code. diff --git a/apps/sim/content/library/mcp-security/index.mdx b/apps/sim/content/library/mcp-security/index.mdx new file mode 100644 index 00000000000..b8c61cc90bb --- /dev/null +++ b/apps/sim/content/library/mcp-security/index.mdx @@ -0,0 +1,118 @@ +--- +slug: mcp-security +title: 'MCP Security: A Practical Guide to Secure MCP Server Development' +description: MCP security covers the risks, auth flaws, and prompt-injection threats in Model Context Protocol servers; here's how to build and deploy MCP servers securely. +date: 2026-07-22 +updated: 2026-07-22 +authors: + - andrew +readingTime: 9 +tags: [MCP Security, MCP, Model Context Protocol, Security, Sim] +ogImage: /library/mcp-security/cover.jpg +ogAlt: Securing Model Context Protocol servers against tool poisoning, auth flaws, and supply-chain risk. +canonical: https://www.sim.ai/library/mcp-security +draft: false +faq: + - q: "What is MCP security?" + a: "MCP security is the practice of keeping MCP servers, clients, and connections from exposing data or executing unintended actions. The main risk categories are prompt and tool injection, authentication flaws like over-scoped tokens and confused-deputy issues, and supply-chain risk from untrusted third-party servers." + - q: "Is MCP secure by default?" + a: "No. The protocol standardizes how agents connect to tools, but security depends entirely on your implementation. Authorization is optional for MCP implementations, so your servers, token handling, and tool definitions determine whether a deployment is actually safe." + - q: "What are the biggest MCP security risks?" + a: "The three to prioritize are prompt and tool injection (especially tool poisoning, where malicious instructions hide in tool metadata), over-scoped tokens and confused-deputy problems in OAuth, and untrusted third-party servers that ship hidden behavior. Tool poisoning is the most prevalent and impactful client-side vulnerability." + - q: "How do you prevent prompt injection in MCP servers?" + a: "Validate and sanitize the inputs and outputs the model can act on, review and version tool definitions to catch silent changes, and require human approval for destructive actions like deletes, writes, and payments. Treating tool metadata as a security boundary is important, since the model reads descriptions as instructions." + - q: "Are local or remote MCP servers safer?" + a: "There's no definitive answer here; each has different trade-offs. Local servers give you control but execute code on infrastructure you host, so a flaw can mean local code execution. Remote servers can live on localhost, a private URL, or a public URL and reduce local execution risk, but they share your data with a third party you must vet and trust." + - q: "How does Sim help secure MCP deployments?" + a: "Sim provides self-hosted deployment for full data control, bring-your-own-keys, workspace and group access permissions, and approval flows. It also captures full run logs with trace spans for every execution, so MCP activity stays observable and governed as usage grows across a team." +--- + +You wired an agent to your internal tools in an afternoon, and it worked. That speed is exactly why MCP security deserves your attention before you ship. The same connection that lets an agent read your database, call your APIs, and run commands is a live attack surface for credential leakage, prompt injection, and unvetted third-party code. + +This guide is for builders who want to ship securely while enjoying the benefits offered by MCP connectivity. We'll take you through defining the risk, building a hardened server, and governing it in production. If you're new to the protocol itself, start with [what an MCP server is](/library/what-is-an-mcp-server). + +## Key Takeaways + +- **MCP security is implementation-dependent:** The protocol standardizes connections, but your servers, tokens, and tool definitions determine whether you're safe. +- **Tool poisoning is the sharpest risk:** Malicious instructions hidden in tool metadata can turn an approved server destructive; these evade reviews that only scan user input. +- **Auth details decide everything:** Over-scoped tokens and token passthrough create confused-deputy problems, so scope narrowly and validate the token audience. +- **Third-party servers are executable code:** Treat unknown MCP servers like any untrusted dependency, vet, sign, and pin them for ultimate security. +- **Security continues after deployment:** Sandboxing, secrets management, egress limits, full run logs, and RBAC keep MCP usage controlled as it scales. + +## What MCP Security Actually Means + +Strong MCP security practices prevent MCP servers, clients, and connections from exposing data or executing unintended actions. A good security strategy should span authentication, tool design, input validation, deployment, and ongoing monitoring. + +MCP connectivity is broken down into three roles. The host runs the LLM, the client speaks the protocol, and the server accesses the actual tools and data while holding the credentials. That server is where most risk concentrates, because it sits closest to your systems. + +The type of threats you should anticipate depends on deployment type. Local servers run on infrastructure you control and often execute OS-level commands, so a flaw can mean code execution on your box. Remote MCP servers can live on localhost, a private URL, or a public URL and are run by others, yet they still touch your data, so you're trusting a third party with sensitive access. + +Adoption is currently outpacing the maturity of MCP servers as a business tool, so the potential for security issues is significant. [Anthropic introduced MCP](https://www.anthropic.com/news/model-context-protocol) in late 2024, and the ecosystem grew fast — public directories like [MCP Market](https://mcpmarket.com/) now index thousands of community-built servers. Many ship faster than they're secured. Platforms like Sim use MCP for custom integrations, which makes MCP security a significant product concern. + +## The Fundamental MCP Security Risks You Need to Know + +These are the threats you design against. The table maps each risk to how it happens and how to shut it down. + +| Risk | How It Happens | Real Impact | Primary Mitigation | +| --- | --- | --- | --- | +| Prompt / Tool Injection | Malicious instructions hidden in tool metadata or external content | Data exfiltration, destructive actions | Review tool definitions, validate inputs/outputs, human approval | +| Confused Deputy / OAuth token misuse | Server forwards or accepts tokens meant for another resource | Privilege escalation across APIs | Validate token audience; use resource indicators | +| Token Passthrough & Over-scoped Credentials | Broad tokens passed to the server or downstream | One breach exposes everything | Least-privilege scopes, per-tool credentials | +| Supply Chain Risk | Untrusted third-party server ships hidden behavior | Backdoors, RCE | Signing, provenance, dependency scanning | +| Local Server Misconfiguration | Unauthenticated server executing OS commands | Local code execution | Auth on every server, sandboxing | +| Session Hijacking | Stolen or predictable session identifiers | Impersonation | Secure session binding, TLS | +| SSRF | Server fetches attacker-controlled URLs | Internal network access | Egress limits, URL allowlists | + +## Three Top MCP Security Concerns To Be Aware Of + +### Prompt and tool injection + +Tool poisoning, where malicious instructions are embedded in tool metadata, is the most prevalent and impactful client-side vulnerability. An amended tool definition can quietly instruct an agent to delete resources or redirect data while looking like ordinary configuration. + +Two documented cases, [MCPoison (CVE-2025-54136)](https://nvd.nist.gov/vuln/detail/CVE-2025-54136) and [CurXecute (CVE-2025-54135)](https://nvd.nist.gov/vuln/detail/CVE-2025-54135), proved the same structural point in Cursor's MCP handling: a trusted configuration could be swapped for a malicious one and reach code execution. Researchers separately demonstrated a [WhatsApp MCP integration flaw](https://www.docker.com/blog/mcp-horror-stories-whatsapp-data-exfiltration-issue/) where a malicious server poisoned tool descriptions, silently redirecting a user's message history to an attacker-controlled number. + +### Authentication + +If an MCP server accepts tokens with incorrect audiences and forwards them unmodified to downstream services, the downstream API incorrectly trusts the token. Over-scoped tokens compound this: hand a server broad credentials and one compromised path exposes everything. OAuth implementation details are where these bugs live. + +### Supply chain risk + +MCP servers are executable code, so an unvetted third-party server can carry hidden behavior. Signing, provenance checks, and dependency scanning are your defenses. + +## How to Build a Secure MCP Server + +### Design With Least Privilege + +Scope every token and permission to the minimum each tool needs, and avoid passing broad credentials to the server. Separate credentials per tool and data source so one compromised path doesn't open the rest. Decide local versus remote deployment based on data sensitivity and trust boundaries before you write any auth code. + +### Harden Authentication and Authorization + +Implement [OAuth](https://oauth.net/2.1/) correctly. An MCP client acts as an OAuth 2.1 client making requests on behalf of a resource owner, and the authorization server issues access tokens for use at the MCP server. + +Enforce per-client consent and validate redirect URIs to close confused-deputy gaps. Critically, the MCP server must not pass through the token it received from the client, and clients must use the resource parameter defined in [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) to specify the target resource. + +Never let the server act as an ambient super-user; enforce the requesting user's permissions on every call. Use the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700), the OAuth 2.0 security best practice published in January 2025, as a guide for best practice. + +### Treat Tool Definitions as Security-Critical Code + +Review and version tool definitions, and detect and block silent changes to tool behavior. Validate and sanitize the inputs and outputs the model can act on to shrink injection blast radius. Add guardrails or human approval for destructive actions like deletes, writes, and payments. + +### Secure the Supply Chain + +Run only trusted, signed servers, and vet third-party servers before connecting. Add SAST and software composition analysis to your build pipeline to catch vulnerable dependencies. Pin versions and monitor for behavioral changes in third-party servers. + +## Deploying and Governing MCP Servers in Production + +Shipping securely is half the job. MCP servers need ongoing governance and monitoring like any production system. + +Start with deployment controls. If you're self-hosting, isolate and sandbox servers so a compromise can't spread. You should also manage secrets outside the codebase, and limit network egress to reduce SSRF and lateral movement. + +Observability comes next. Log every tool call and MCP interaction, trace execution end to end, and alert on anomalous requests such as mass deletes, unusual data access, or injection patterns. You can't investigate what you don't record. + +Access governance keeps things controlled as teams scale. Implement RBAC, approval workflows, separate staging and production, and audit trails to keep track of activity surrounding your MCP servers. + +This is where a specialized platform helps. Sim is an AI workspace where teams build MCP-connected agents with enterprise controls, so security lives in the platform instead of being bolted on. You get self-hosted deployment for full data control, bring-your-own-keys, workspace and group access permissions, and full run logs with trace spans for every execution. Custom integrations connect through Sim's MCP support so your controls apply consistently. For the wider self-hosting landscape, see [open-source AI agent platforms](/library/open-source-ai-agent-platforms). + +## What To Do Next + +Treat your MCP server as untrusted until you've narrowed its tokens, versioned its tool definitions, and put every tool call under logs and approval gates. Pick your highest-risk server today and audit its security using the processes detailed above. If you're comparing where to run MCP-connected agents, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) covers the field. If you're standardizing MCP across a team, [start building on Sim](https://sim.ai) so self-hosting, access control, and observability come built in. diff --git a/apps/sim/content/library/n8n-alternatives/index.mdx b/apps/sim/content/library/n8n-alternatives/index.mdx index 26067b4f59f..7378582bd13 100644 --- a/apps/sim/content/library/n8n-alternatives/index.mdx +++ b/apps/sim/content/library/n8n-alternatives/index.mdx @@ -3,7 +3,7 @@ slug: n8n-alternatives title: '10 Best n8n Alternatives for AI Agent Workflows in 2026' description: Comparing the 10 best n8n alternatives in 2026 for AI agent workflows - covering Sim, Make, Zapier, Activepieces, Pipedream, and more, with pricing and use cases. date: 2026-07-13 -updated: 2026-07-13 +updated: 2026-07-23 authors: - emir readingTime: 15 @@ -31,7 +31,7 @@ This guide covers 10 n8n alternatives for two distinct groups: teams that want s - **n8n's core friction points in 2026:** Self-hosting overhead, execution-based pricing that escalates at volume, and AI capabilities bolted onto a pre-agentic architecture push teams toward purpose-built alternatives. - **For AI agent workflows:** Sim offers native multi-model orchestration, agent memory, and MCP support as fully integral features rather than add-ons. Gumloop and Botpress serve more specific AI niches (visual LLM workflows and conversational agents, respectively). - **For simpler SaaS automation:** Make provides the best debugging experience for visual workflow builders. Zapier offers the widest integration catalog. Neither is built for agentic AI. -- **Open source matters, but licenses vary:** Activepieces uses a genuine MIT license with no commercial restrictions. n8n's Sustainable Use License restricts commercial redistribution, which isn't true open source by OSI standards. +- **Open source matters, but licenses vary:** Activepieces uses a genuine MIT license with no commercial restrictions. n8n's [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) restricts commercial redistribution, which isn't true open source by OSI standards. We unpack the practical consequences in [Apache 2.0 vs fair-code](/library/apache-2-0-vs-fair-code). - **Migration is never a weekend project:** Moving a portfolio of n8n workflows to any alternative requires real engineering time. Calculate total cost of ownership, including DevOps hours, before committing. - **Self-hosting isn't automatically cheaper:** Factor in server costs, database hosting, security patches, and maintenance hours against the subscription price of managed alternatives before assuming you'll save money. @@ -72,14 +72,14 @@ This table summarizes all 10 tools across the dimensions that matter most when e | Tool | Best For | AI Agent Depth | Open Source | Starting Price | | --- | --- | --- | --- | --- | | **Sim** | AI agent workflows with multi-model orchestration | Native (memory, multi-agent, MCP) | Yes | Free plan available | -| **Make** | Visual SaaS automation with strong debugging | One-shot AI nodes only | No | Free tier; paid from ~$12/mo | -| **Zapier** | Widest integration catalog, fastest setup | Basic (Zapier Agents, Copilot) | No | Free tier; paid from ~$20/mo | -| **Activepieces** | Open-source self-hosting with MIT license | Growing (AI agents, MCP servers) | Yes (MIT) | Free self-hosted; cloud from $5/flow/mo | +| **[Make](https://www.make.com/en/pricing)** | Visual SaaS automation with strong debugging | One-shot AI nodes only | No | Free tier; paid from $12/mo | +| **[Zapier](https://zapier.com/pricing)** | Widest integration catalog, fastest setup | Basic (Zapier Agents, Copilot) | No | Free tier; paid from $19.99/mo (annual) | +| **[Activepieces](https://www.activepieces.com/pricing)** | Open-source self-hosting with MIT license | Growing (AI agents, MCP servers) | Yes (MIT) | Free self-hosted; cloud from $5/flow/mo after 10 free | | **Pipedream** | Code-first automation without infra management | Not AI-agent native | Partial | Free tier available | -| **Gumloop** | Non-developers building LLM-powered workflows | Native visual AI canvas | No | Verify on site | -| **Lindy** | Task-specific AI agents (email, meetings, sales) | Pre-built agent templates | No | ~$49.99/mo | -| **Botpress** | Conversational AI agents (chat, voice) | Native (memory, RAG, goals) | Partial | Verify on site | -| **Microsoft Power Automate** | Microsoft 365/Azure ecosystem teams | AI Builder (add-on cost) | No | Included with some M365 plans | +| **[Gumloop](https://www.gumloop.com/pricing)** | Non-developers building LLM-powered workflows | Native visual AI canvas | No | See pricing page | +| **[Lindy](https://www.lindy.ai/pricing)** | Task-specific AI agents (email, meetings, sales) | Pre-built agent templates | No | $49.99/mo | +| **[Botpress](https://botpress.com/pricing)** | Conversational AI agents (chat, voice) | Native (memory, RAG, goals) | Partial | See pricing page | +| **[Microsoft Power Automate](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing)** | Microsoft 365/Azure ecosystem teams | AI Builder (add-on cost) | No | $15/user/mo; included with some M365 plans | | **Workato** | Enterprise iPaaS with SLAs and compliance | Enterprise orchestration | No | Custom pricing | ## The 10 Best n8n Alternatives in 2026 @@ -95,7 +95,7 @@ This table summarizes all 10 tools across the dimensions that matter most when e - **1,000+ integrations with multi-model LLM support:** Connect OpenAI, Claude, Gemini, Groq, Mistral, xAI, and local models via Ollama or vLLM. MCP (Model Context Protocol) support lets you wire in custom integrations through a standardized protocol, so you're not limited to the pre-built connector library. - **Real-time collaboration and Chat:** Multiple team members can build workflows simultaneously with live editing, commenting, and granular permissions. Chat lets you build workflows in natural language — talk to Sim to ask questions, plan, and build, cutting the time from idea to working agent significantly. - **Deployment flexibility that directly solves n8n's self-hosting pain:** Choose cloud-managed infrastructure with auto-scaling and built-in observability, or self-host via Docker Compose and Kubernetes. You get the data control of self-hosting without being forced into it. -- **Enterprise-ready security:** SOC2 and HIPAA compliant, trusted by over 100,000 builders across startups to Fortune 500 companies. Open source, so your team can audit the code. +- **Enterprise-ready security:** SOC2 compliant, trusted by over 100,000 builders across startups to Fortune 500 companies. Open source, so your team can audit the code. **Pricing:** Free plan available with no credit card required. Pro and Max plans use credit-based billing with a base execution charge of $0.005 per workflow execution. The BYOK (Bring Your Own Keys) option lets you use your own API keys at base pricing with no markup, eliminating the AI cost inflation that plagues other platforms. Team plans pool credits across seats with 500 GB shared storage. @@ -115,7 +115,7 @@ Make's color-coded visual scenario builder is genuinely the best debugging exper **Limitations:** Make has added AI modules for OpenAI and Claude, but they function as one-shot nodes only. There's no native multi-agent orchestration, no agent memory, and AI can't reason across steps. If you need agentic capabilities, Make won't provide them. -**Pricing:** Make offers a free plan with 1,000 credits per month. Paid plans start at $12/month (billed annually) for 10,000 credits (Core). Each module action in a scenario counts as one credit, including triggers, filters, and routers, so a single Zapier task might equal 3-8 Make operations. Monitor your credit consumption carefully as workflows grow more complex. +**Pricing:** [Make](https://www.make.com/en/pricing) offers a free plan with 1,000 credits per month. Paid plans start at $12/month billed monthly for 10,000 credits (Core), with roughly 15% off for annual billing. Each module action in a scenario counts as one credit, including triggers, filters, and routers, so a single Zapier task might equal 3-8 Make operations. Monitor your credit consumption carefully as workflows grow more complex. ### Zapier @@ -144,7 +144,7 @@ Activepieces is the cleanest open-source alternative to n8n if licensing terms m - **MIT license with no strings attached:** The codebase lives on GitHub under the MIT license, the most permissive of open-source licenses, giving organizations complete freedom to self-host, modify, fork, and extend the platform without any licensing fees or usage restrictions. - **Growing integration and MCP ecosystem:** All 750+ pieces are available as MCP servers that you can use with LLMs on Claude Desktop, Cursor, or Windsurf. The community contributes actively, with 60% of the pieces contributed by the community. - **Clean, accessible UI:** Activepieces has the most polished UI of any open-source automation platform, making it accessible to non-technical users while still supporting code steps for custom logic. -- **Flat-rate cloud pricing:** Cloud pricing starts free with 10 free active flows, then $5 per active flow per month with unlimited runs. No per-execution billing surprises. +- **Flat-rate cloud pricing:** [Cloud pricing](https://www.activepieces.com/pricing) starts free with 10 free active flows, then $5 per active flow per month with unlimited runs. No per-execution billing surprises. **Limitations:** The connector ecosystem is smaller than n8n, Make, or Zapier. Enterprise features like SSO, audit logs, and custom RBAC are available under the commercial license, so fully free self-hosted deployments miss those governance capabilities. @@ -196,7 +196,7 @@ Lindy takes a different approach than most platforms on this list. Instead of gi **Limitations:** Credit-based pricing frustrates teams with high automation volume. Lindy is better for task-specific agents than complex multi-agent orchestration, and it's limited to its defined use case categories. If your workflow doesn't fit Lindy's templates, you'll hit bottlenecks pretty quickly. -**Pricing:** Paid plans start at approximately $49.99/month. Verify current pricing on Lindy's website. +**Pricing:** [Lindy's](https://www.lindy.ai/pricing) Plus plan is $49.99/month, with Pro at $99.99 and Max at $199.99. There is a 7-day trial but no permanent free plan. ### Botpress @@ -250,11 +250,11 @@ Workato sits at the opposite end of the spectrum from open-source tools. It's a Many teams looking for an n8n open source alternative care about one of three things: self-hosting flexibility, data sovereignty, or cost reduction. How the licensing works for each alternative matters more than most comparison articles acknowledge. -**n8n uses fair-code, not open source.** Although n8n's source code is available under the Sustainable Use License, according to the Open Source Initiative (OSI), open source licenses can't include limitations on use, so n8n does not call itself open source. In practical terms, the license allows you the free right to use, modify, create derivative works, and redistribute, with three limitations, the most significant being that the moment automation becomes a value proposition for external users, the license blocks it. For internal business use, this rarely matters. For agencies, consultancies, or SaaS companies embedding automation into customer-facing products, it's a dealbreaker. +**n8n uses fair-code, not open source.** Although n8n's source code is available under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), according to the [Open Source Initiative (OSI)](https://opensource.org/osd), open source licenses can't include limitations on use, so n8n does not call itself open source. In practical terms, the license allows you the free right to use, modify, create derivative works, and redistribute, with three limitations, the most significant being that the moment automation becomes a value proposition for external users, the license blocks it. For internal business use, this rarely matters. For agencies, consultancies, or SaaS companies embedding automation into customer-facing products, it's a dealbreaker. **Activepieces uses the MIT license.** Unlike many competitors that keep their code proprietary, Activepieces' MIT license allows you to use, adapt, and scale the platform without restrictions. You can embed it in commercial products, resell hosted instances, or fork the codebase without legal risk. This is the permissive open-source experience most developers expect when they hear the term "open source." -**Sim is open source** with deployment options via Docker Compose and Kubernetes for teams that want full infrastructure control. Combined with SOC2 and HIPAA compliance, this gives enterprise teams the data control they need without sacrificing security certifications. +**Sim is open source** with deployment options via Docker Compose and Kubernetes for teams that want full infrastructure control. Combined with SOC2 compliance, this gives enterprise teams the data control they need without sacrificing a recognized security certification. **Migration is not a weekend project.** Moving a portfolio of workflows from n8n to any alternative requires real engineering effort. A close format match (like moving to Activepieces, where the trigger-action paradigm is similar) typically takes a few weeks of focused work for a mid-size workflow portfolio. Switching to a different paradigm entirely, like moving from n8n's node-based approach to Sim's agentic architecture or Pipedream's code-first model, takes longer because you're rearchitecting workflows, not just porting them. @@ -271,3 +271,5 @@ If you're leaving because self-hosting is consuming too much engineering time, M If licensing restrictions concern you, Activepieces offers the cleanest MIT-licensed alternative with a growing integration ecosystem. Don't try to evaluate all 10 tools on this list. Identify which of the four archetypes from the framework section fits your team, narrow the list to two or three candidates, and build a real workflow in each. The free tiers across Sim, Make, Zapier, Activepieces, and Pipedream allow you to do this without any upfront spend. + +For adjacent shortlists: [best Zapier alternatives](/library/best-zapier-alternatives) covers the same market from the per-task-pricing angle, [open-source AI agent platforms](/library/open-source-ai-agent-platforms) narrows to self-hostable options, and [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) ranks the field on agent capability rather than integration count. diff --git a/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx b/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx index 80196709e36..94eb6e714bb 100644 --- a/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx +++ b/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx @@ -3,7 +3,7 @@ slug: open-source-ai-agent-platforms title: 'Open-Source AI Agent Platforms: Comparison' description: Compare the top open-source AI agent platforms of 2026 - LangGraph, CrewAI, AutoGen, Dify, n8n, and Sim - by architecture, production readiness, and team fit. Find the right one for your use case. date: 2026-07-13 -updated: 2026-07-13 +updated: 2026-07-23 authors: - emir readingTime: 12 @@ -29,9 +29,9 @@ This guide splits open-source AI agent platforms into three clear categories, co ## Key Takeaways - **Three categories, not one:** Code-first frameworks (LangGraph, CrewAI, AutoGen), visual builders (Dify, n8n), and AI workspaces (Sim) serve fundamentally different buyers and require different evaluations. -- **AutoGen is splintering:** Microsoft's AutoGen has fractured into maintenance mode, a community-led AG2 fork, and the new Microsoft Agent Framework. Teams need to choose the option that will work best for them. +- **AutoGen is splintering:** Microsoft's [AutoGen](https://microsoft.github.io/autogen/stable/) has fractured into maintenance mode, a community-led AG2 fork, and the new Microsoft Agent Framework. Teams need to choose the option that will work best for them. - **CrewAI Flows changed the game:** CrewAI's Flows feature adds event-driven orchestration alongside crew-style collaboration, giving teams both flexibility and control in one framework. -- **Dify dominates the visual builder space:** With over 131,000 GitHub stars and a recent $30M raise, Dify is the most-adopted visual AI agent builder, though it still lacks strong team governance features. +- **Dify dominates the visual builder space:** With over [149,000 GitHub stars](https://github.com/langgenius/dify) and a recent $30M raise, Dify is the most-adopted visual AI agent builder, though it still lacks strong team governance features. - **Workspace platforms bundle what frameworks leave out:** Sim combines visual building, team collaboration, knowledge management, and deployment infrastructure in a single open-source package, reducing the "glue code" problem. - **Self-hosting and licensing vary widely:** "Open source" means different things across these platforms, from fully permissive MIT licenses to open-core models where enterprise features sit behind paid tiers. @@ -59,7 +59,7 @@ That control comes at a cost: these frameworks assume you have engineers who can ### LangGraph -LangGraph is the default choice for complex stateful workflows that need explicit control over branching, retries, and human-in-the-loop. It sits on top of the LangChain ecosystem and has seen the largest enterprise adoption among code-first agent frameworks. +[LangGraph](https://langchain-ai.github.io/langgraph/) is the default choice for complex stateful workflows that need explicit control over branching, retries, and human-in-the-loop. It sits on top of the LangChain ecosystem and has seen the largest enterprise adoption among code-first agent frameworks. LangGraph does four things excellently: branching logic that lets you define exactly which path an agent takes based on state, human-led approvals and checkpointing that are now integral features rather than add-ons, durable execution that survives process restarts, and detailed control over every step in the agent's decision chain. @@ -69,7 +69,7 @@ Where it demands investment: setup isn't trivial, especially for teams new to gr ### CrewAI -CrewAI introduced a mental model that's simple to pick up: instead of defining abstract graph nodes, you define roles. A researcher agent gathers information, a writer agent drafts content, a reviewer agent checks quality. CrewAI is the fastest path from idea to working multi-agent prototype when work decomposes into role-based tasks. +[CrewAI](https://docs.crewai.com/) introduced a mental model that's simple to pick up: instead of defining abstract graph nodes, you define roles. A researcher agent gathers information, a writer agent drafts content, a reviewer agent checks quality. CrewAI is the fastest path from idea to working multi-agent prototype when work decomposes into role-based tasks. The Flows addition lets you create structured, event-driven workflows that provide a way to connect multiple tasks, manage state, and control the flow of execution in your AI applications. This is a meaningful evolution. Flows give you a structured, event-driven execution engine that sits above individual crews and tasks. A Crew is great at parallel collaboration with multiple agents working on a shared goal, but Crews don't give you sequential control. Think of it this way: a Crew is a team, a Flow is the project plan that coordinates multiple teams. @@ -99,11 +99,11 @@ Visual builders trade code-level control for speed. They let teams design agent Dify's open-source model with 131k GitHub stars targets production scalability. That star count makes it the most-starred visual AI agent builder in the open-source space by a wide margin, and it reflects genuine production adoption. -Dify is a production-ready platform for agentic workflow development, handling everything from enterprise QA bots to AI-driven custom assistants. The platform includes a workflow builder for defining tool-using agents, built-in RAG (retrieval-augmented generation) pipeline management, support for multiple AI model providers, and Model Context Protocol (MCP) integration. +[Dify](https://dify.ai/pricing) is a production-ready platform for agentic workflow development, handling everything from enterprise QA bots to AI-driven custom assistants. The platform includes a workflow builder for defining tool-using agents, built-in RAG (retrieval-augmented generation) pipeline management, support for multiple AI model providers, and Model Context Protocol (MCP) integration. The RAG pipeline is Dify's standout feature. It's among the best available in an open-source package. If your primary use case involves document retrieval, knowledge bases, and structured Q&A, Dify's built-in tooling eliminates weeks of integration work. -The self-hosted Community Edition (Docker Compose, single machine or Kubernetes) is free with no significant limitations. Dify Cloud starts with a free Sandbox tier and scales to Professional ($59/month), Team ($159/month), and Enterprise (custom) plans. +The self-hosted Community Edition (Docker Compose, single machine or Kubernetes) is free with no significant limitations. [Dify Cloud](https://dify.ai/pricing) starts with a free Sandbox tier at 200 message credits and scales to Professional, Team, and Enterprise plans; the Professional tier lists at $590/year and Team at $1,590/year. Where Dify falls short relative to a purpose-built AI workspace: team governance is limited, agent lifecycle management (versioning, rollback, multi-user editing) lacks depth, and the visual tooling has a ceiling; complex custom logic belongs in code. @@ -134,7 +134,7 @@ The feature set maps directly to what teams need in a comparison context: - **Real-time collaboration:** Multiple team members building workflows simultaneously with live editing, commenting, and granular permission controls - **Deployment flexibility:** Cloud-hosted with managed infrastructure, or self-hosted via Docker Compose or Kubernetes for complete data control -The open-source commitment is backed by community traction: 18,000 GitHub stars and over 100,000 builders, alongside SOC2 and HIPAA compliance as production trust signals. Those compliance certifications are important when the conversation moves from "prototype" to "production" and legal needs to sign off. +The open-source commitment is backed by community traction: over 100,000 builders, alongside SOC2 compliance as a production trust signal. That certification is important when the conversation moves from "prototype" to "production" and legal needs to sign off. Chat, Sim's natural-language interface, lets you talk to Sim to build and manage agents conversationally alongside the visual builder. That dual-interface approach means teams can choose which way of working suits them best, rather than being locked into one process. @@ -190,3 +190,5 @@ Visual builders like Dify and n8n lower the barrier to entry but trade away arch Start with the decision paths above. Identify which category fits your team, then evaluate within that category. Trying to compare a Python framework against a visual workspace on the same checklist is how teams end up six months into a tool that doesn't fit. If your team needs collaboration, multi-model support, and a visual builder with production-grade deployment, [explore Sim](https://sim.ai) and see whether the workspace model matches how your team actually works. + +Related reading: [Apache 2.0 vs fair-code](/library/apache-2-0-vs-fair-code) explains why license choice changes what you can build on a self-hosted platform, [LangGraph alternatives](/library/langgraph-alternatives) goes deeper on the code-first category, and [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) widens the field to commercial options alongside these. diff --git a/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx b/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx index d25bbaaf1de..fc2f84d49f5 100644 --- a/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx +++ b/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx @@ -3,7 +3,7 @@ slug: openai-vs-n8n-vs-sim title: 'OpenAI AgentKit vs n8n vs Sim: AI Agent Workflow Builder Comparison' description: OpenAI just released AgentKit for building AI agents. How does it compare to workflow automation platforms like n8n and purpose-built AI agent builders like Sim? date: 2025-10-06 -updated: 2025-10-06 +updated: 2026-07-23 authors: - emir readingTime: 9 @@ -30,7 +30,7 @@ When building AI agent workflows, developers often evaluate multiple platforms t ## What is OpenAI AgentKit? -OpenAI AgentKit is a set of building blocks designed to help developers take AI agents from prototype to production. Built on top of the OpenAI Responses API, it provides a structured approach to building and deploying intelligent agents. +[OpenAI AgentKit](https://developers.openai.com/api/docs/guides/agents) is a set of building blocks designed to help developers take AI agents from prototype to production. Built on top of the OpenAI Responses API, it provides a structured approach to building and deploying intelligent agents. ![OpenAI AgentKit workflow interface](/library/openai-vs-n8n-vs-sim/openai.png) @@ -221,3 +221,5 @@ Choose **OpenAI AgentKit** if you're exclusively using OpenAI models and want to Choose **n8n** if your primary use case is traditional workflow automation between business tools, with occasional AI enhancement. It's ideal for organizations already familiar with n8n who want to add some AI capabilities to existing automations. Choose **Sim** if you're building AI agents as your primary objective and need a platform purpose-built for that use case. Sim provides the most comprehensive feature set for agentic workflows, with AI Copilot to accelerate development, parallel execution handling, intelligent knowledge base for RAG applications, detailed execution logging for production monitoring, flexibility across AI providers, extensive integrations, team collaboration, and deployment options that scale from prototype to production. + +Going wider than these three? [The best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) compares the full field, [10 best n8n alternatives](/library/n8n-alternatives) covers n8n's competitors specifically, and [Apache 2.0 vs fair-code](/library/apache-2-0-vs-fair-code) explains why n8n's license matters if you plan to self-host commercially. diff --git a/apps/sim/content/library/what-is-an-mcp-server/index.mdx b/apps/sim/content/library/what-is-an-mcp-server/index.mdx new file mode 100644 index 00000000000..37102908960 --- /dev/null +++ b/apps/sim/content/library/what-is-an-mcp-server/index.mdx @@ -0,0 +1,124 @@ +--- +slug: what-is-an-mcp-server +title: 'What Is an MCP Server?' +description: 'Learn what an MCP server is, how Model Context Protocol discovery, tools, and transports work, and how Sim consumes and publishes MCP tools for AI agents.' +date: 2026-07-24 +updated: 2026-07-24 +authors: + - andrew +readingTime: 10 +tags: [MCP, AI Agents, Model Context Protocol, Sim] +ogImage: /library/what-is-an-mcp-server/cover.jpg +canonical: https://www.sim.ai/library/what-is-an-mcp-server +draft: false +faq: + - q: "What does an MCP server do?" + a: "An MCP server exposes tools, resources, and prompts through the Model Context Protocol so a compatible AI client can discover and use those capabilities through a standard interface." + - q: "How is an MCP server different from a REST API?" + a: "A REST integration usually requires client-specific endpoint, schema, and authentication code. MCP adds a standard discovery and invocation layer, allowing compatible clients to list a server's capabilities and call its tools through the same protocol." + - q: "Which MCP transport should I use?" + a: "Use stdio when the client and server run on the same machine. Use Streamable HTTP for remote or multi-client deployments. HTTP+SSE is the deprecated legacy transport and is mainly relevant for backwards compatibility." + - q: "Can Sim use and publish MCP tools?" + a: "Yes. Sim can connect to external MCP servers and execute their tools inside workflows. It can also expose deployed workflows as tools on a workflow MCP server for compatible clients to discover and call." +--- + +## TL;DR + +An MCP server is a program that exposes tools, data, and prompts to AI models through the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-06-18/architecture), a standard interface any MCP-compatible client can call. + +- An MCP server lets an LLM or agent [discover and call external capabilities](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) without a developer hardcoding each one. +- [Anthropic introduced and open-sourced MCP in November 2024](https://www.anthropic.com/news/model-context-protocol). It uses [JSON-RPC 2.0 messages](https://modelcontextprotocol.io/specification/2025-06-18/basic) and is supported by clients including [Claude Desktop](https://modelcontextprotocol.io/quickstart/user), [Cursor](https://docs.cursor.com/context/model-context-protocol), and [VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). +- MCP replaces dozens of bespoke API integrations with a [single client-server architecture](https://modelcontextprotocol.io/specification/2025-06-18/architecture), so compatible clients can use the same server. +- Sim works in both directions. It calls external MCP servers as tools, and it publishes deployed Sim workflows as tools that other AI systems can call through MCP. +- The [spec defines two current transports](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports), stdio for local processes and Streamable HTTP for remote calls. The older HTTP+SSE transport is deprecated and should not be used for new builds. +- The comparison table below shows how MCP servers, traditional API integrations, and Sim workflows-as-MCP-tools differ on setup effort, discovery, and reusability. + +## What is an MCP server? + +An MCP server is a program that exposes tools, data sources, and prompts to an AI model through the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-06-18/architecture), a shared standard for connecting language models to external capabilities. + +The point of an MCP server is to give an LLM or agent a consistent way to reach the outside world. Instead of writing custom glue for every API a model needs, you run an MCP server that advertises its capabilities in a format the model understands. An [MCP-compatible client](https://modelcontextprotocol.io/specification/2025-06-18/architecture) can then connect, list what the server offers, and call it. If you are new to the software on the other side of that connection, this guide to [AI agents and chatbots](https://www.sim.ai/library/ai-agent-vs-chatbot) explains why tool use matters. + +[Model Context Protocol](https://modelcontextprotocol.io) was [introduced and open-sourced by Anthropic in November 2024](https://www.anthropic.com/news/model-context-protocol) to solve a coordination problem. Each new tool, database, or service used to require its own integration written against a specific model or framework, and none of that work carried over. MCP defines one interface for all of them, built on [JSON-RPC 2.0](https://modelcontextprotocol.io/specification/2025-06-18/basic), so a server built once works with clients that speak the protocol. Adoption moved fast: [Claude Desktop](https://modelcontextprotocol.io/quickstart/user), [Cursor](https://docs.cursor.com/context/model-context-protocol), and [VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) all document MCP client support. + +A single MCP server can expose three kinds of capability. [**Tools**](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) are actions the model can invoke, like sending an email or querying a database. [**Resources**](https://modelcontextprotocol.io/specification/2025-06-18/server/resources) are data the model can read, like files or records. [**Prompts**](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts) are reusable templates the server offers to guide a task. The client asks the server what it supports, and the model or user decides what to call. + +Because the interface is standardized, the same MCP server can work across [compatible clients](https://modelcontextprotocol.io/specification/2025-06-18/architecture) without modification. A server you build for one agent can answer calls from another when it connects and negotiates a supported protocol version and capability set. + +## How does an MCP server differ from a traditional API integration? + +A traditional API integration forces a developer to hardcode each endpoint by hand, while an MCP server lets a client [discover the available tools at runtime](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). When you connect to a REST API, you read the docs, map the request and response shapes, and write code specific to that one service. Connect to a second service, and you repeat the process with a different auth scheme and schema. An MCP server flips this. Through the protocol's [tool discovery flow](https://modelcontextprotocol.io/specification/2025-06-18/server/tools), a client queries the server and receives tool names, descriptions, and input schemas. It can then call those tools without the developer wiring each underlying endpoint into that client ahead of time. The server owns its tool definitions rather than forcing every client to maintain a field-by-field mapping. + +Authentication becomes a per-server concern from the client's perspective. Every conventional API may use a different scheme, which can mean new token handling, headers, and refresh logic for each integration. For HTTP transports, MCP defines an [authorization framework based on OAuth 2.1](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization), so a client authenticates to the MCP server rather than directly implementing auth for every downstream tool it exposes. The server still has to manage its downstream credentials. Adding another tool behind an existing server does not necessarily require new auth code in the client. REST APIs can publish machine-readable descriptions through formats such as the [OpenAPI Specification](https://spec.openapis.org/oas/latest.html), but that description is separate from the invocation layer and is not shared automatically by every client. MCP makes capability discovery part of the protocol, which is what lets exposed tools travel across compatible clients. + +## How is an MCP server built? + +Three things determine how a server behaves once an agent connects to it. Discovery answers "what can you do?", tool wrapping defines the individual actions, and the transport layer decides how bytes move between client and server. Build all three well and any compatible client can use your server without a custom adapter. + +### Discovery + +[Discovery](https://modelcontextprotocol.io/specification/2025-06-18/architecture) lets a client query an MCP server for its available tools, resources, and prompts at connection time. The protocol provides [list operations and capability negotiation](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle), and a [tool list](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools) includes each tool's name, description, and input schema. An agent reads that response and decides which tool to call, so endpoint shapes do not have to be hardcoded into the client. [Servers can also notify clients that a tool list changed](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#list-changed-notification) when both sides declare support for that capability. + +### Tool wrapping + +Tool wrapping turns an existing function, database query, or REST API into a callable MCP tool. You write a thin adapter that declares the tool's schema and maps the model's arguments onto whatever the underlying code expects. A weather API becomes a `get_forecast` tool, a SQL query becomes a `search_orders` tool, and the agent calls both through the same [MCP tool invocation interface](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). Wrapping is where most of your build effort goes, since it defines the contract the model reasons about. + +### Transport options + +The [MCP transport specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) defines two current transports. A third, the original HTTP+SSE design, is deprecated and should not be used for new builds. + +- [**stdio**](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) runs the server as a local subprocess and pipes JSON-RPC messages over standard input and output. Use it for local tools, desktop clients, and processes on the same machine where you want no network setup. Messages are newline-delimited UTF-8, and the server can write logs to stderr. +- [**Streamable HTTP**](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) exposes a single MCP endpoint that handles both directions. The client POSTs JSON-RPC requests to it, and the server returns either a standard HTTP response or an SSE stream. This is the transport for remote, multi-client, or horizontally scaled deployments. +- [**HTTP+SSE (legacy, deprecated)**](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#backwards-compatibility) was the original network design. It used separate SSE and POST endpoints. Streamable HTTP replaced it, and the specification retains compatibility guidance for clients and servers that still need to interoperate with older implementations. + +A point worth clearing up, because it trips up a lot of teams: "SSE" and "HTTP" are not two peer transports sitting alongside stdio. [SSE is a response mechanism used by Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http), while "the SSE transport" commonly refers to the deprecated two-endpoint HTTP+SSE design. If you are building something new, use Streamable HTTP. If you maintain a legacy server, plan migration around the clients you need to support. + +You can wire the same set of wrapped tools to either current transport without rewriting the tools themselves, since the [protocol architecture separates server capabilities from transports](https://modelcontextprotocol.io/specification/2025-06-18/architecture). A common pattern is stdio for local development and Streamable HTTP for production. + +The [Sim MCP docs](https://docs.sim.ai/mcp) show how discovery, wrapping, and transport selection appear when you connect a server in practice, including which transport to pick for local versus deployed setups. + +## Can Sim act as both an MCP client and an MCP server? + +Yes. Sim works in both directions, which means you can consume external tools and expose your own from the same platform. + +As an MCP client, Sim calls external MCP servers as tools inside your workflows. You configure a server in a Sim workspace, select its tools for an agent or MCP block, and Sim can invoke those published tools. If you run an MCP server for a database, search index, or internal service, Sim discovers its tools and calls them alongside native integrations. You get access to the MCP ecosystem without writing a custom Sim integration for each server. + +As an MCP server, Sim can publish deployed workflows as tools on a workflow MCP server that other AI systems call. Build and deploy a workflow in Sim, add it to a workflow MCP server, and a compatible client can discover and invoke it. Whatever logic you assembled inside Sim, whether it chains models, calls APIs, or runs branching decisions, appears to the caller as a tool with a defined schema. For the broader build-and-deploy flow, see [how to create an AI agent with Sim](https://www.sim.ai/library/how-to-create-an-ai-agent). + +That two-way capability changes how you compose systems. You can connect Sim to a third-party MCP server, build a workflow on top of it, and then publish that workflow as an MCP tool for something else to consume. One Sim deployment can sit in the middle of a chain, acting as a client to the servers below it and a server to the clients above it. + +The practical payoff is reuse. You build a workflow once, deploy it, and compatible clients can call it without knowing how it works internally. You skip the usual step of writing and maintaining a separate API wrapper for each consumer. + +Setup for both roles lives in the [Sim MCP docs](https://docs.sim.ai/mcp), which walk through connecting external servers as tools and publishing deployed workflows through a workflow MCP server. + +## Why teams run MCP servers on Sim + +Standing up an MCP server yourself means writing the wrapper, hosting it, handling auth, and maintaining it as the specification evolves. Sim provides a workflow layer around that work, and three qualities matter for teams that need to control their deployment. + +**It is Apache 2.0 licensed.** Sim's repository ships under Apache 2.0, a permissive open-source license that allows use, modification, commercial distribution, and sublicensing and includes an express patent grant. The practical implications are covered in [Apache 2.0 vs fair-code](https://www.sim.ai/library/apache-2-0-vs-fair-code). + +**You can run it on your own infrastructure, including isolated environments.** A self-hosted Sim deployment keeps the workflow and MCP execution layer within infrastructure you control. That matters when exposed tools touch regulated or private data. Teams comparing deployment and governance models can also review these [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). + +**Admins control access to MCP capabilities.** Sim's workspace permissions govern MCP server management, and permission-group settings can hide MCP deployment or disable MCP tools for selected users. Workflow execution traces record activity block by block, giving operators a detailed view of what the workflow did when a tool was called. + +You can build the workflow behind the server in natural language with Mothership, assemble it on the visual canvas, or define it through the API. The exposed MCP tool presents the same deployed workflow logic either way. + +## MCP server vs traditional API integration vs Sim workflow-as-MCP-tool + +Three approaches let an AI system reach an external tool, and they differ in how much work you do up front and how far the result travels. The table below compares them on setup effort, discovery support, reusability across clients, and who can call the result. + +| | Setup effort | Discovery support | Reusability across clients | Who can call it | +| --- | --- | --- | --- | --- | +| **MCP server** | Moderate. You wrap tools once behind the protocol. | [Built in](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). Clients query the server for its available tools. | High. [Compatible clients](https://modelcontextprotocol.io/specification/2025-06-18/architecture) connect without a bespoke integration. | Any MCP client that supports the server's transport and protocol version. | +| **Traditional API integration** | High. You implement endpoints, auth flows, and schemas for each client integration. | Not required by REST itself. Discovery depends on documentation or an additional description format. | Low by default. Each new client needs integration code. | Applications with code written for that API. | +| **Sim workflow-as-MCP-tool** | Low. You build and deploy a workflow, then add it to a workflow MCP server. | Built in. Sim exposes the deployed workflow as a discoverable tool. | High. Compatible AI systems can connect to its MCP server. | MCP clients that can reach and authenticate to the Sim endpoint. | + +A traditional API integration can lock a tool to one application and force the next team to repeat the client work. An MCP server breaks that pattern by exposing tools through a discovery layer compatible clients can read, so one server serves many callers. A Sim workflow-as-MCP-tool pushes the effort down further: you build and deploy the logic in Sim, then publish it through a workflow MCP server. See [docs.sim.ai/mcp](https://docs.sim.ai/mcp) for setup details. + +## Conclusion + +MCP servers solve a real problem for anyone building agentic systems. Instead of writing a custom integration for every tool an agent needs, you expose those tools through one standard interface that compatible clients can discover and call. That standardization is why the protocol spread quickly after Anthropic released it. + +Sim gives you both directions in one place. You can wire external MCP servers into a workflow as tools, and you can expose deployed Sim workflows through an MCP server for other AI systems to call, on infrastructure you control under the Apache 2.0 license. + +Start by reading [docs.sim.ai/mcp](https://docs.sim.ai/mcp) and deploy your first MCP server today. diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 47a258f5f9a..1b70ad89a26 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react' import { Checkbox, Chip, @@ -37,6 +37,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -53,6 +54,7 @@ import { useRemovePermissionGroupMember, useUpdatePermissionGroup, } from '@/ee/access-control/hooks/permission-groups' +import { SettingRow } from '@/ee/components/setting-row' import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' import { useOrganizationRoster } from '@/hooks/queries/organization' import { useProviderModels } from '@/hooks/queries/providers' @@ -90,6 +92,227 @@ const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTION (o) => o.value ) +type StatusFilter = 'all' | 'enabled' | 'disabled' + +const STATUS_FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: 'Show all' }, + { value: 'enabled', label: 'Show enabled' }, + { value: 'disabled', label: 'Show disabled' }, +] + +function matchesStatusFilter(filter: StatusFilter, enabled: boolean) { + return filter === 'all' || (filter === 'enabled') === enabled +} + +interface StatusFilterChipProps { + value: StatusFilter + onChange: (value: StatusFilter) => void + /** Set when the chip is the last control in its row, so it sits flush to the edge. */ + flush?: boolean +} + +/** The All/Enabled/Disabled narrowing control shared by the three list tabs. */ +function StatusFilterChip({ value, onChange, flush }: StatusFilterChipProps) { + return ( + onChange(next as StatusFilter)} + options={STATUS_FILTER_OPTIONS} + matchTriggerWidth={false} + flush={flush} + className='w-[140px] flex-shrink-0' + /> + ) +} + +interface AuthModeFieldProps { + label: string + value: ShareAuthType[] + onChange: (values: string[]) => void + options: { value: ShareAuthType; label: string }[] + disabled: boolean +} + +/** + * The allowed-auth-modes multi-select nested under a platform toggle. Dims and + * disables together with the toggle that owns it. The left padding lines both + * children up with the parent's label text — row gutter (8) + checkbox (16) + + * gap (8) = 32 — so the field reads as subordinate rather than as a sibling row. + * The dropdown is `flush` so its own `mx-0.5` doesn't push it 2px past the + * caption above it. + */ +function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFieldProps) { + const labelId = useId() + const triggerId = useId() + return ( +
+ + {label} + + +
+ ) +} + +/** Render order for the platform-feature category sections; unlisted ones follow. */ +const PLATFORM_CATEGORY_ORDER = [ + 'Sidebar', + 'Deploy Tabs', + 'Chat', + 'Collaboration', + 'Workflow Panel', + 'Tools', + 'Features', + 'Settings Tabs', + 'Logs', + 'Files', +] + +const PLATFORM_FEATURES = [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab' as const, + hint: 'Hide the Knowledge Base module from the sidebar.', + }, + { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + configKey: 'hideTablesTab' as const, + hint: 'Hide the Tables module from the sidebar.', + }, + { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + configKey: 'hideCopilot' as const, + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }, + { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + configKey: 'hideIntegrationsTab' as const, + hint: 'Hide the Integrations settings tab (OAuth connections).', + }, + { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + configKey: 'hideSecretsTab' as const, + hint: 'Hide the Secrets (environment variables) settings tab.', + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab' as const, + hint: 'Hide the API Keys settings tab.', + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab' as const, + hint: 'Hide the Files settings tab.', + }, + { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + configKey: 'hideDeployApi' as const, + hint: 'Hide the API deployment option.', + }, + { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + configKey: 'hideDeployMcp' as const, + hint: 'Hide the MCP server deployment option.', + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools' as const, + hint: 'Block agents from calling MCP tools.', + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools' as const, + hint: 'Block agents from calling user-defined custom tools.', + }, + { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + configKey: 'disableSkills' as const, + hint: 'Block agents from loading skills.', + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans' as const, + hint: 'Hide per-block trace spans in logs.', + }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations' as const, + hint: 'Prevent users from inviting others to workspaces.', + }, + { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + configKey: 'hideInboxTab' as const, + hint: 'Hide the Sim Mailer inbox.', + }, + { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + configKey: 'disablePublicApi' as const, + hint: 'Disable public API access to deployed workflows.', + }, + // Chat and Files get a category of their own so their nested auth-mode + // dropdown (see `featureExtras`) reads as part of the toggle it qualifies. + { + id: 'hide-deploy-chatbot', + label: 'Deployment', + category: 'Chat', + configKey: 'hideDeployChatbot' as const, + hint: 'Hide the chat deployment option.', + }, + { + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + configKey: 'disablePublicFileSharing' as const, + hint: 'Disable public file-share links.', + }, +] + interface OrganizationMemberOption { userId: string user: { @@ -521,7 +744,7 @@ function BlockToolRow({ onClick={() => isBlockAllowed && isExpandable && setExpanded((prev) => !prev)} disabled={!isBlockAllowed || !isExpandable} className={cn( - 'flex flex-1 items-center gap-2 text-left', + 'flex min-w-0 flex-1 items-center gap-2 text-left', isBlockAllowed && isExpandable ? 'cursor-pointer' : 'cursor-default', !isBlockAllowed && 'opacity-60' )} @@ -541,6 +764,12 @@ function BlockToolRow({ /> )} + {/* Outside the button: an Info trigger is itself a button and cannot nest. */} + {block.description && ( + + {block.description} + + )}
{expanded && isBlockAllowed && isExpandable && (
@@ -595,11 +824,15 @@ export function GroupDetail({ */ const [viewingGroup, setViewingGroup] = useState(group) const [editingConfig, setEditingConfig] = useState({ ...group.config }) + const [editingName, setEditingName] = useState(group.name.trim()) + const [editingDescription, setEditingDescription] = useState((group.description ?? '').trim()) const prevGroupIdRef = useRef(group.id) if (prevGroupIdRef.current !== group.id) { prevGroupIdRef.current = group.id setViewingGroup(group) setEditingConfig({ ...group.config }) + setEditingName(group.name.trim()) + setEditingDescription((group.description ?? '').trim()) } /** @@ -613,6 +846,9 @@ export function GroupDetail({ const [providerSearchTerm, setProviderSearchTerm] = useState('') const [integrationSearchTerm, setIntegrationSearchTerm] = useState('') const [platformSearchTerm, setPlatformSearchTerm] = useState('') + const [providerStatusFilter, setProviderStatusFilter] = useState('all') + const [blockStatusFilter, setBlockStatusFilter] = useState('all') + const [platformStatusFilter, setPlatformStatusFilter] = useState('all') const [showAddMembersModal, setShowAddMembersModal] = useState(false) const [addMembersError, setAddMembersError] = useState(null) @@ -676,141 +912,24 @@ export function GroupDetail({ return map }, [allBlocks]) - const platformFeatures = useMemo( - () => [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab' as const, - hint: 'Hide the Knowledge Base module from the sidebar.', - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab' as const, - hint: 'Hide the Tables module from the sidebar.', - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot' as const, - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab' as const, - hint: 'Hide the Integrations settings tab (OAuth connections).', - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab' as const, - hint: 'Hide the Secrets (environment variables) settings tab.', - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab' as const, - hint: 'Hide the API Keys settings tab.', - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab' as const, - hint: 'Hide the Files settings tab.', - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi' as const, - hint: 'Hide the API deployment option.', - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp' as const, - hint: 'Hide the MCP server deployment option.', - }, - { - id: 'hide-deploy-chatbot', - label: 'Chat', - category: 'Deploy Tabs', - configKey: 'hideDeployChatbot' as const, - hint: 'Hide the chatbot deployment option.', - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools' as const, - hint: 'Block agents from calling MCP tools.', - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools' as const, - hint: 'Block agents from calling user-defined custom tools.', - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills' as const, - hint: 'Block agents from loading skills.', - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans' as const, - hint: 'Hide per-block trace spans in logs.', - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations' as const, - hint: 'Prevent users from inviting others to workspaces.', - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab' as const, - hint: 'Hide the Sim Mailer inbox.', - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi' as const, - hint: 'Disable public API access to deployed workflows.', - }, - ], - [] - ) + const searchedPlatformFeatures = useMemo(() => { + const search = platformSearchTerm.trim().toLowerCase() + if (!search) return PLATFORM_FEATURES + return PLATFORM_FEATURES.filter( + (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + ) + }, [platformSearchTerm]) + /** Split from the search pass for the same reason as the provider and block lists. */ const filteredPlatformFeatures = useMemo(() => { - if (!platformSearchTerm.trim()) return platformFeatures - const search = platformSearchTerm.toLowerCase() - return platformFeatures.filter( - (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + if (platformStatusFilter === 'all') return searchedPlatformFeatures + return searchedPlatformFeatures.filter((f) => + matchesStatusFilter(platformStatusFilter, !editingConfig[f.configKey]) ) - }, [platformFeatures, platformSearchTerm]) + }, [searchedPlatformFeatures, platformStatusFilter, editingConfig]) const platformCategories = useMemo(() => { - const categories: Record = {} + const categories: Record = {} for (const feature of filteredPlatformFeatures) { if (!categories[feature.category]) { categories[feature.category] = [] @@ -821,19 +940,9 @@ export function GroupDetail({ }, [filteredPlatformFeatures]) const platformCategorySections = useMemo(() => { - const order = [ - 'Sidebar', - 'Deploy Tabs', - 'Collaboration', - 'Workflow Panel', - 'Tools', - 'Features', - 'Settings Tabs', - 'Logs', - ] - const known = order.filter((c) => platformCategories[c]?.length) + const known = PLATFORM_CATEGORY_ORDER.filter((c) => platformCategories[c]?.length) const extras = Object.keys(platformCategories).filter( - (c) => c !== 'Files' && !order.includes(c) && platformCategories[c]?.length + (c) => !PLATFORM_CATEGORY_ORDER.includes(c) && platformCategories[c]?.length ) return [...known, ...extras].map((category) => ({ category, @@ -844,20 +953,82 @@ export function GroupDetail({ const hasConfigChanges = useMemo(() => { return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig) }, [viewingGroup.config, editingConfig]) - const guard = useSettingsUnsavedGuard({ isDirty: hasConfigChanges }) - const filteredProviders = useMemo(() => { - if (!providerSearchTerm.trim()) return allProviderIds - const query = providerSearchTerm.toLowerCase() + // Both buffers are seeded trimmed and compared against a trimmed baseline. The + // contract trims name and description on write, but a row stored before those + // schemas gained `.trim()` (or written straight to the API) can still carry + // padding — compared raw it would open dirty with no way to clear it, since + // Discard restores the same padded value. + const trimmedName = editingName.trim() + const trimmedDescription = editingDescription.trim() + const nameChanged = trimmedName !== viewingGroup.name.trim() + const descriptionChanged = trimmedDescription !== (viewingGroup.description ?? '').trim() + const hasChanges = hasConfigChanges || nameChanged || descriptionChanged + + const guard = useSettingsUnsavedGuard({ isDirty: hasChanges }) + + /** + * `null` means "everything allowed". Indexing the allow-lists once keeps the + * per-row membership checks O(1) — they run for every one of the ~200 block + * rows on each render, and again in the section-wide `every(...)` scans. + */ + const allowedIntegrationSet = useMemo( + () => + editingConfig.allowedIntegrations === null + ? null + : new Set(editingConfig.allowedIntegrations), + [editingConfig.allowedIntegrations] + ) + + const allowedProviderSet = useMemo( + () => + editingConfig.allowedModelProviders === null + ? null + : new Set(editingConfig.allowedModelProviders), + [editingConfig.allowedModelProviders] + ) + + const isIntegrationAllowed = useCallback( + (blockType: string) => allowedIntegrationSet === null || allowedIntegrationSet.has(blockType), + [allowedIntegrationSet] + ) + + const isProviderAllowed = useCallback( + (providerId: string) => allowedProviderSet === null || allowedProviderSet.has(providerId), + [allowedProviderSet] + ) + + const searchedProviders = useMemo(() => { + const query = providerSearchTerm.trim().toLowerCase() + if (!query) return allProviderIds return allProviderIds.filter((id) => id.toLowerCase().includes(query)) }, [allProviderIds, providerSearchTerm]) - const filteredBlocks = useMemo(() => { - if (!integrationSearchTerm.trim()) return visibleBlocks - const query = integrationSearchTerm.toLowerCase() + /** + * Split from the search pass so the common `all` case returns the searched + * list by reference — only the status pass depends on the allow-list, so a + * checkbox toggle no longer invalidates downstream consumers. + */ + const filteredProviders = useMemo(() => { + if (providerStatusFilter === 'all') return searchedProviders + return searchedProviders.filter((id) => + matchesStatusFilter(providerStatusFilter, isProviderAllowed(id)) + ) + }, [searchedProviders, providerStatusFilter, isProviderAllowed]) + + const searchedBlocks = useMemo(() => { + const query = integrationSearchTerm.trim().toLowerCase() + if (!query) return visibleBlocks return visibleBlocks.filter((b) => b.name.toLowerCase().includes(query)) }, [visibleBlocks, integrationSearchTerm]) + const filteredBlocks = useMemo(() => { + if (blockStatusFilter === 'all') return searchedBlocks + return searchedBlocks.filter((b) => + matchesStatusFilter(blockStatusFilter, isIntegrationAllowed(b.type)) + ) + }, [searchedBlocks, blockStatusFilter, isIntegrationAllowed]) + const filteredCoreBlocks = useMemo( () => filteredBlocks.filter((block) => block.category === 'blocks'), [filteredBlocks] @@ -886,13 +1057,6 @@ export function GroupDetail({ return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId)) }, [organizationMembers, members]) - const isIntegrationAllowed = useCallback( - (blockType: string) => - editingConfig.allowedIntegrations === null || - editingConfig.allowedIntegrations.includes(blockType), - [editingConfig.allowedIntegrations] - ) - /** * Drops denied tools whose integration is no longer allowed, keeping the * invariant that `deniedTools` only holds tools of currently-allowed blocks. @@ -1003,13 +1167,6 @@ export function GroupDetail({ return counts }, [editingConfig.deniedTools, allBlocks]) - const isProviderAllowed = useCallback( - (providerId: string) => - editingConfig.allowedModelProviders === null || - editingConfig.allowedModelProviders.includes(providerId), - [editingConfig.allowedModelProviders] - ) - const toggleProvider = useCallback( (providerId: string) => { setEditingConfig((prev) => { @@ -1130,7 +1287,7 @@ export function GroupDetail({ const setChatDeployAuthTypes = useCallback((values: string[]) => { // At least one mode must stay allowed while chat deploy is enabled — an empty // allow-list would silently block every chat deployment. To turn chat deploy - // off entirely, use the Hide Chat toggle instead. + // off entirely, uncheck Chat → Deployment instead. if (values.length === 0) return setEditingConfig((prev) => ({ ...prev, @@ -1139,26 +1296,66 @@ export function GroupDetail({ })) }, []) - /** Persists the editing buffer. */ - const handleSaveConfig = useCallback(async () => { + /** + * Nested controls rendered under a platform feature's checkbox, keyed by + * feature id. Kept out of `PLATFORM_FEATURES` so that array stays pure data. + */ + const featureExtras: Partial> = { + 'hide-deploy-chatbot': ( + + ), + 'disable-public-file-sharing': ( + + ), + } + + /** Persists the editing buffer — name/description are only sent when they changed. */ + const handleSaveConfig = async () => { + if (!trimmedName) return try { - await updatePermissionGroup.mutateAsync({ + const result = await updatePermissionGroup.mutateAsync({ id: viewingGroup.id, organizationId, - config: editingConfig, + ...(hasConfigChanges && { config: editingConfig }), + ...(nameChanged && { name: trimmedName }), + ...(descriptionChanged && { description: trimmedDescription || null }), }) - setViewingGroup((prev) => ({ ...prev, config: editingConfig })) + // Reconcile from the server's copy, like the scope/default writes do, so a + // server-side normalization can't leave the dirty check comparing against a + // baseline that was never persisted. Editing buffers are left alone so + // in-flight edits survive and correctly re-mark the form dirty. + const saved = result.permissionGroup + setViewingGroup((prev) => ({ + ...prev, + config: saved.config, + name: saved.name, + description: saved.description, + })) } catch (error) { - logger.error('Failed to update config', error) + logger.error('Failed to save permission group', error) toast.error("Couldn't save changes", { description: getErrorMessage(error, 'Please try again in a moment.'), }) } - }, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup]) + } - const handleDiscardConfig = useCallback(() => { + const handleDiscardConfig = () => { setEditingConfig({ ...viewingGroup.config }) - }, [viewingGroup.config]) + setEditingName(viewingGroup.name.trim()) + setEditingDescription((viewingGroup.description ?? '').trim()) + } const handleBack = useCallback(() => { guard.guardBack(onBack) @@ -1307,10 +1504,11 @@ export function GroupDetail({ description={viewingGroup.description ?? undefined} actions={[ ...saveDiscardActions({ - dirty: hasConfigChanges, + dirty: hasChanges, saving: updatePermissionGroup.isPending, onSave: handleSaveConfig, onDiscard: handleDiscardConfig, + saveDisabled: !trimmedName, }), { text: deletePermissionGroup.isPending ? 'Deleting...' : 'Delete', @@ -1330,6 +1528,31 @@ export function GroupDetail({ {configTab === 'general' && ( <> + +
+ + setEditingName(e.target.value)} + placeholder='e.g., Marketing Team' + maxLength={100} + error={!trimmedName} + /> + {!trimmedName && ( +

Name is required.

+ )} +
+ + setEditingDescription(e.target.value)} + placeholder='e.g., Limited access for marketing users' + maxLength={500} + /> + +
+
+
@@ -1457,27 +1680,36 @@ export function GroupDetail({ onChange={(e) => setProviderSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)} + disabled={filteredProviders.length === 0} > {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
-
- {filteredProviders.map((providerId) => ( - toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} - workspaceId={workspaceId} - isAllowed={isModelAllowed} - onToggle={toggleModel} - onSetDenied={setModelsDenied} - /> - ))} -
+ {filteredProviders.length === 0 ? ( + + No providers match your filters. + + ) : ( +
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isAllowed={isModelAllowed} + onToggle={toggleModel} + onSetDenied={setModelsDenied} + /> + ))} +
+ )}
)} @@ -1491,7 +1723,13 @@ export function GroupDetail({ onChange={(e) => setIntegrationSearchTerm(e.target.value)} className='min-w-0 flex-1' /> +
+ {filteredCoreBlocks.length === 0 && filteredToolBlocks.length === 0 && ( + + No blocks match your filters. + + )} {filteredCoreBlocks.length > 0 && ( - toggleIntegration(block.type)} - /> -
- {BlockIcon && } -
- {block.name} - + toggleIntegration(block.type)} + /> +
+ {BlockIcon && } +
+ {block.name} + + {block.description && ( + + {block.description} + + )} +
) })}
@@ -1579,6 +1826,7 @@ export function GroupDetail({ onChange={(e) => setPlatformSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + setEditingConfig((prev) => ({ @@ -1588,101 +1836,49 @@ export function GroupDetail({ ), })) } + flush + disabled={filteredPlatformFeatures.length === 0} > {platformAllVisible ? 'Deselect All' : 'Select All'}
+ {platformCategorySections.length === 0 && ( + + No features match your filters. + + )} {platformCategorySections.map(({ category, features }) => (
{features.map((feature) => ( -
- - {feature.hint} +
+
+ + + {feature.hint} + +
+ {featureExtras[feature.id]}
))}
))} - -
- - Auth modes chat deployments may use - - -
-
- -
- -
- - Auth modes public file-share links may use - - -
-
-
)} diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 666088d3a69..898123fc7b4 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -101,27 +101,49 @@ interface EditingPolicy { isNew: boolean } +/** Day bounds the retention contract accepts (1 day … 5 years). */ +const MIN_RETENTION_DAYS = 1 +const MAX_RETENTION_DAYS = 1825 + +/** + * Hours → display days, clamped to the contract's range. Sub-day values would + * otherwise round to `0` and be re-sent as `0`, wedging every save on the page. + */ +function clampDisplayDays(hours: number): string { + const days = Math.round(hours / 24) + return String(Math.min(MAX_RETENTION_DAYS, Math.max(MIN_RETENTION_DAYS, days))) +} + +/** Day count → hours. Throws rather than send a value the contract rejects. */ +function toRetentionHours(days: string): number { + const parsed = Number(days) + if (!Number.isFinite(parsed) || parsed < MIN_RETENTION_DAYS) { + throw new Error(`Invalid retention period: ${JSON.stringify(days)}`) + } + return Math.min(MAX_RETENTION_DAYS, Math.round(parsed)) * 24 +} + function hoursToDisplayDays(hours: number | null): string { if (hours === null) return 'never' - return String(Math.round(hours / 24)) + return clampDisplayDays(hours) } function daysToHours(days: string): number | null { if (days === 'never') return null - return Number(days) * 24 + return toRetentionHours(days) } /** Override field: `INHERIT` ⇄ undefined, `'never'` ⇄ null (forever), day count ⇄ hours. */ function hoursToOverrideValue(hours: number | null | undefined): string { if (hours === undefined) return INHERIT if (hours === null) return 'never' - return String(Math.round(hours / 24)) + return clampDisplayDays(hours) } function overrideValueToHours(value: string): number | null | undefined { if (value === INHERIT) return undefined if (value === 'never') return null - return Number(value) * 24 + return toRetentionHours(value) } function buildRetentionOverride(workspaceId: string, draft: PolicyDraft): RetentionOverride | null { diff --git a/apps/sim/ee/sso/components/domain-settings.tsx b/apps/sim/ee/sso/components/domain-settings.tsx new file mode 100644 index 00000000000..04f10e6a8d3 --- /dev/null +++ b/apps/sim/ee/sso/components/domain-settings.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useState } from 'react' +import { Button, ChipConfirmModal, ChipCopyInput, ChipInput, ChipTag, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { OrganizationDomain } from '@/lib/api/contracts/organization' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + useAddOrganizationDomain, + useOrganizationDomains, + useRemoveOrganizationDomain, + useVerifyOrganizationDomain, +} from '@/ee/sso/hooks/domains' + +interface DomainSettingsProps { + organizationId: string +} + +interface CopyFieldProps { + label: string + value: string + hint?: string +} + +function CopyField({ label, value, hint }: CopyFieldProps) { + return ( +
+ {label} + + {hint ? {hint} : null} +
+ ) +} + +interface DomainRowProps { + organizationId: string + domain: OrganizationDomain + onRemove: (domain: OrganizationDomain) => void +} + +function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { + const verifyDomain = useVerifyOrganizationDomain() + + async function handleVerify() { + try { + await verifyDomain.mutateAsync({ orgId: organizationId, domainId: domain.id }) + toast.success(`${domain.domain} verified`) + } catch (error) { + toast.error(getErrorMessage(error, 'Verification failed — check the DNS record and retry')) + } + } + + return ( +
+
+ {domain.domain} +
+ + {domain.status === 'verified' ? 'Verified' : 'Pending'} + + onRemove(domain), destructive: true }]} + /> +
+
+ + {domain.status === 'pending' && domain.txtRecordValue && ( +
+

+ Add this TXT record at your DNS provider, then verify. DNS changes can take up to 48 + hours to propagate. +

+ + +
+ +
+
+ )} +
+ ) +} + +export function DomainSettings({ organizationId }: DomainSettingsProps) { + const { data, isLoading } = useOrganizationDomains(organizationId) + const addDomain = useAddOrganizationDomain() + const removeDomain = useRemoveOrganizationDomain() + + const [newDomain, setNewDomain] = useState('') + const [pendingRemoval, setPendingRemoval] = useState(null) + + if (isLoading) { + return ( + + Loading domains... + + ) + } + + if (data && !data.isEnterprise) { + return ( + + + Domain verification is available on Enterprise plans only. + + + ) + } + + async function handleAdd() { + const value = newDomain.trim() + if (!value) return + try { + await addDomain.mutateAsync({ orgId: organizationId, body: { domain: value } }) + setNewDomain('') + toast.success(`${value} added — add the DNS record and verify`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to add domain')) + } + } + + async function handleConfirmRemove() { + if (!pendingRemoval) return + try { + await removeDomain.mutateAsync({ orgId: organizationId, domainId: pendingRemoval.id }) + setPendingRemoval(null) + toast.success(`${pendingRemoval.domain} removed`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to remove domain')) + } + } + + const domains = data?.domains ?? [] + + return ( + <> + +
+
+

+ Verify domains your organization owns. A domain must be verified before you can + configure SSO for it. +

+
+ setNewDomain(event.target.value)} + placeholder='acme.com' + className='min-w-0 flex-1' + /> + +
+
+ + {domains.length === 0 ? ( + No domains yet. + ) : ( +
+ {domains.map((domain) => ( + + ))} +
+ )} +
+
+ + !open && setPendingRemoval(null)} + title='Remove domain' + text={[ + 'Remove ', + { text: pendingRemoval?.domain ?? '', bold: true }, + "? You'll need to verify it again before you can configure SSO for it. Existing SSO sign-in is not affected.", + ]} + confirm={{ + label: 'Remove', + onClick: handleConfirmRemove, + pending: removeDomain.isPending, + pendingLabel: 'Removing...', + }} + /> + + ) +} diff --git a/apps/sim/ee/sso/hooks/domains.ts b/apps/sim/ee/sso/hooks/domains.ts new file mode 100644 index 00000000000..1b2b4a8906a --- /dev/null +++ b/apps/sim/ee/sso/hooks/domains.ts @@ -0,0 +1,72 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type AddOrganizationDomainBody, + addOrganizationDomainContract, + listOrganizationDomainsContract, + type OrganizationDomains, + removeOrganizationDomainContract, + verifyOrganizationDomainContract, +} from '@/lib/api/contracts/organization' + +export type DomainsResponse = OrganizationDomains + +export const DOMAINS_STALE_TIME = 60 * 1000 + +export const domainKeys = { + all: ['orgDomains'] as const, + lists: () => [...domainKeys.all, 'list'] as const, + list: (orgId: string) => [...domainKeys.lists(), orgId] as const, +} + +async function fetchDomains(orgId: string, signal?: AbortSignal): Promise { + const { data } = await requestJson(listOrganizationDomainsContract, { + params: { id: orgId }, + signal, + }) + return data +} + +export function useOrganizationDomains(orgId: string | undefined) { + return useQuery({ + queryKey: domainKeys.list(orgId ?? ''), + queryFn: ({ signal }) => fetchDomains(orgId as string, signal), + enabled: Boolean(orgId), + staleTime: DOMAINS_STALE_TIME, + }) +} + +export function useAddOrganizationDomain() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ orgId, body }: { orgId: string; body: AddOrganizationDomainBody }) => + requestJson(addOrganizationDomainContract, { params: { id: orgId }, body }), + onSettled: (_data, _error, { orgId }) => { + queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + }, + }) +} + +export function useVerifyOrganizationDomain() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ orgId, domainId }: { orgId: string; domainId: string }) => + requestJson(verifyOrganizationDomainContract, { params: { id: orgId, domainId } }), + onSettled: (_data, _error, { orgId }) => { + queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + }, + }) +} + +export function useRemoveOrganizationDomain() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ orgId, domainId }: { orgId: string; domainId: string }) => + requestJson(removeOrganizationDomainContract, { params: { id: orgId, domainId } }), + onSettled: (_data, _error, { orgId }) => { + queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + }, + }) +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index d3863d3fb6b..e7413a06236 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -545,14 +545,26 @@ describe('copyForkResourceContainers external MCP server copy', () => { }) describe('copyForkResourceContainers skill copy', () => { - function makeSkillTx(rows: Array>) { + /** Sequential tx mock: each select resolves the next queued row set (skill rows, then member rows). */ + function makeSkillTx(selects: Array>>) { + let call = 0 const inserted: Array> = [] const tx = { - select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }), + select: () => { + const result = Promise.resolve(selects[call++] ?? []) + const chain = { + from: () => chain, + innerJoin: () => chain, + where: () => result, + } + return chain + }, insert: () => ({ values: (values: Array>) => { inserted.push(...values) - return Promise.resolve() + return Object.assign(Promise.resolve(), { + onConflictDoNothing: () => Promise.resolve(), + }) }, }), } @@ -568,20 +580,20 @@ describe('copyForkResourceContainers skill copy', () => { knowledgeBases: [], } + const sourceSkillRow = { + id: 'sk-1', + name: 'My Skill', + description: 'desc', + workspaceId: 'src-ws', + userId: 'src-user', + createdAt: new Date(), + updatedAt: new Date(), + } + it('copies the skill body IN-DB and carries only the child id in the content plan', async () => { // The source projection deliberately omits `content` (it is copied server-side), so the row // fed to the tx mock has none - the body must never be materialized in app memory here. - const { tx, inserted } = makeSkillTx([ - { - id: 'sk-1', - name: 'My Skill', - description: 'desc', - workspaceId: 'src-ws', - userId: 'src-user', - createdAt: new Date(), - updatedAt: new Date(), - }, - ]) + const { tx, inserted } = makeSkillTx([[sourceSkillRow], []]) const result = await copyForkResourceContainers({ tx, @@ -604,6 +616,37 @@ describe('copyForkResourceContainers skill copy', () => { expect(result.contentPlan.skills).toEqual([{ childId }]) expect(result.names.skills).toEqual(['My Skill']) }) + + it('copies editor grants onto the child skill for users in the target roster', async () => { + // The editor query joins the child-workspace permissions in-DB, so the + // mock's second row set already represents source editors ∩ target roster. + const { tx, inserted } = makeSkillTx([ + [sourceSkillRow], + [ + { skillId: 'sk-1', userId: 'editor-1' }, + { skillId: 'sk-1', userId: 'editor-2' }, + ], + ]) + + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: skillSelection, + workflowIdMap: new Map(), + }) + + const childSkill = inserted[0] + const firstGrant = inserted[1] + expect(firstGrant.skillId).toBe(childSkill.id) + expect(firstGrant.userId).toBe('editor-1') + + const secondGrant = inserted[2] + expect(secondGrant.skillId).toBe(childSkill.id) + expect(secondGrant.userId).toBe('editor-2') + }) }) describe('copyForkResourceContainers knowledge-base tag definitions', () => { 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 2c7eda693b5..63c32d56549 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -6,7 +6,9 @@ import { knowledgeBase, knowledgeBaseTagDefinitions, mcpServers, + permissions, skill, + skillMember, userTableDefinitions, userTableRows, workflowMcpServer, @@ -335,6 +337,7 @@ export async function copyForkResourceContainers( .from(skill) .where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId))) const inserts: SkillSkeletonInsert[] = [] + const childSkillIdBySource = new Map() for (const row of rows) { const childId = generateId() inserts.push({ @@ -348,11 +351,52 @@ export async function copyForkResourceContainers( createdAt: now, updatedAt: now, }) + childSkillIdBySource.set(row.id, childId) record('skill', row.id, childId) contentPlan.skills.push({ childId }) names.skills.push(row.name) } - if (inserts.length > 0) await tx.insert(skill).values(inserts) + if (inserts.length > 0) { + await tx.insert(skill).values(inserts) + + // Copy editor grants for users who are members of the child workspace. + // Workspace admins need no rows — they are derived editors in the child + // too (mirrors credential member propagation otherwise). + const memberRows = await tx + .select({ + skillId: skillMember.skillId, + userId: skillMember.userId, + }) + .from(skillMember) + .innerJoin( + permissions, + and( + eq(permissions.userId, skillMember.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, childWorkspaceId) + ) + ) + .where(inArray(skillMember.skillId, Array.from(childSkillIdBySource.keys()))) + const memberInserts = memberRows.flatMap((member) => { + const childSkillId = childSkillIdBySource.get(member.skillId) + if (!childSkillId) return [] + return [ + { + id: generateId(), + skillId: childSkillId, + userId: member.userId, + createdAt: now, + updatedAt: now, + }, + ] + }) + if (memberInserts.length > 0) { + await tx + .insert(skillMember) + .values(memberInserts) + .onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] }) + } + } } if (selection.mcpServers.length > 0) { diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 94d291a18c5..fe4bb0e624d 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -26,6 +26,14 @@ vi.mock('@/lib/uploads', () => ({ }, })) +vi.mock('@/lib/logs/execution/pii-redaction', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + redactObjectStrings: vi.fn(actual.redactObjectStrings), + } +}) + function createBlock(): SerializedBlock { return { id: 'function-block-1', @@ -379,4 +387,362 @@ describe('BlockExecutor', () => { ) expect(state.getBlockOutput(block.id)).toEqual(output) }) + + it('does not soft-succeed non-agent blocks on user AbortError', async () => { + const block = createBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const abortController = new AbortController() + const handler: BlockHandler = { + canHandle: () => true, + execute: async () => { + abortController.abort('user') + throw new DOMException('The operation was aborted.', 'AbortError') + }, + } + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/abort/i) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeTruthy() + expect(output).not.toEqual({ content: '' }) + }) +}) + +describe('BlockExecutor streaming pump', () => { + function createAgentBlock(): SerializedBlock { + return { + id: 'agent-block-1', + metadata: { id: BlockType.AGENT, name: 'Agent' }, + position: { x: 0, y: 0 }, + config: { tool: BlockType.AGENT, params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + } + + function createExecutor(handler: BlockHandler) { + const block = createAgentBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + return { executor, block, state } + } + + function createAgentEventsStreamingHandler(options: { + events: Array> + attachThinkingOnDrain?: string + failAfterText?: string + onFullContent?: (content: string) => void | Promise + }): BlockHandler { + return { + canHandle: () => true, + execute: async () => { + const timeSegment: Record = { + type: 'model', + name: 'claude-test', + startTime: Date.now(), + endTime: Date.now(), + duration: 1, + } + const output = { + content: '', + model: 'claude-test', + tokens: { input: 1, output: 2, total: 3 }, + providerTiming: { + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + duration: 1, + timeSegments: [timeSegment], + }, + cost: { input: 0, output: 0, total: 0 }, + } + + const stream = new ReadableStream({ + start(controller) { + if (options.failAfterText) { + controller.enqueue({ + type: 'text_delta', + text: options.failAfterText, + turn: 'final', + }) + controller.error(new Error('provider reset')) + return + } + for (const event of options.events) { + controller.enqueue(event) + } + if (options.attachThinkingOnDrain) { + timeSegment.thinkingContent = options.attachThinkingOnDrain + } + controller.close() + }, + }) + + return { + stream, + streamFormat: 'agent-events-v1' as const, + execution: { + success: true, + output, + logs: [], + metadata: { + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + duration: 1, + }, + }, + onFullContent: options.onFullContent, + } + }, + } + } + + it('projects answer text to onStream and content; sink gets full timeline', async () => { + const onFullContent = vi.fn() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'thinking_delta', text: 'hmm ' }, + { type: 'thinking_delta', text: 'yes' }, + { type: 'text_delta', text: 'Hello ', turn: 'final' }, + { type: 'text_delta', text: 'world', turn: 'final' }, + ], + attachThinkingOnDrain: 'hmm yes', + onFullContent, + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + const forwarded: string[] = [] + const sinkEvents: Array> = [] + + ctx.onStream = async (streamingExec) => { + expect(streamingExec.streamFormat).toBe('text') + streamingExec.subscribe?.({ + onEvent: async (event) => { + sinkEvents.push(event as Record) + }, + }) + const reader = streamingExec.stream.getReader() + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + forwarded.push(decoder.decode(value, { stream: true })) + } + } + + await executor.execute(ctx, createNode(block), block) + + expect(forwarded.join('')).toBe('Hello world') + expect(state.getBlockOutput(block.id)?.content).toBe('Hello world') + expect(onFullContent).toHaveBeenCalledWith('Hello world') + expect(sinkEvents).toEqual([ + { type: 'thinking_delta', text: 'hmm ' }, + { type: 'thinking_delta', text: 'yes' }, + { type: 'text_delta', text: 'Hello ', turn: 'final' }, + { type: 'text_delta', text: 'world', turn: 'final' }, + ]) + expect(state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent).toBe( + 'hmm yes' + ) + }) + + it('drains without onStream and still persists answer content', async () => { + const handler = createAgentEventsStreamingHandler({ + events: [{ type: 'text_delta', text: 'offline answer', turn: 'final' }], + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + + await executor.execute(ctx, createNode(block), block) + + expect(state.getBlockOutput(block.id)?.content).toBe('offline answer') + }) + + it('throws on mid-stream provider error (no truncated success)', async () => { + const handler = createAgentEventsStreamingHandler({ + failAfterText: 'partial', + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.onStream = async (streamingExec) => { + const reader = streamingExec.stream.getReader() + try { + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // consumer may see the error; block must still fail + } + } + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow('provider reset') + expect(state.getBlockOutput(block.id)?.content).not.toBe('partial') + }) + + it('soft-completes on user abort with drained answer text (no failed block)', async () => { + const abortController = new AbortController() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'text_delta', text: 'partial answer', turn: 'final' }, + { type: 'thinking_delta', text: 'more' }, + ], + }) + + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + ctx.onStream = async (streamingExec) => { + streamingExec.subscribe?.({ onEvent: async () => {} }) + const reader = streamingExec.stream.getReader() + try { + // Drain the first projected answer chunk, then Stop — pump must keep it. + const first = await reader.read() + expect(first.done).toBe(false) + abortController.abort('user') + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // abort may cancel the text stream + } + } + + await executor.execute(ctx, createNode(block), block) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeUndefined() + // Soft-complete must keep text already projected before Stop — not empty content. + expect(output?.content).toBe('partial answer') + expect(output).not.toMatchObject({ error: expect.any(String) }) + }) + + it('fails on timeout but keeps drained answer text in block output', async () => { + const abortController = new AbortController() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'text_delta', text: 'partial before timeout', turn: 'final' }, + { type: 'thinking_delta', text: 'more' }, + ], + }) + + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + ctx.onStream = async (streamingExec) => { + streamingExec.subscribe?.({ onEvent: async () => {} }) + const reader = streamingExec.stream.getReader() + try { + const first = await reader.read() + expect(first.done).toBe(false) + abortController.abort('timeout') + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // timeout may cancel the text stream + } + } + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/timed out/i) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeTruthy() + expect(output?.content).toBe('partial before timeout') + }) + + it('with PII redaction: no live forward and strips thinking from traces', async () => { + const { redactObjectStrings } = await import('@/lib/logs/execution/pii-redaction') + vi.mocked(redactObjectStrings).mockImplementation(async (value) => { + if (typeof value === 'string') { + return `[masked]${value}` as never + } + // Object walk is exercised elsewhere; keep streaming-stage string mask as-is. + return value as never + }) + + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'thinking_delta', text: 'secret thought' }, + { type: 'text_delta', text: 'alice@example.com said hi', turn: 'final' }, + ], + attachThinkingOnDrain: 'secret thought', + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + const onStream = vi.fn() + ctx.onStream = onStream + ctx.piiBlockOutputRedaction = { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + } + + await executor.execute(ctx, createNode(block), block) + + expect(onStream).not.toHaveBeenCalled() + expect(state.getBlockOutput(block.id)?.content).toBe('[masked]alice@example.com said hi') + expect( + state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent + ).toBeUndefined() + }) }) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index b5591983d05..f4b12617509 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1,5 +1,6 @@ import { createLogger, type Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types' import { redactApiKeys } from '@/lib/core/security/redaction' import { normalizeStringArray } from '@/lib/core/utils/arrays' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -59,6 +60,7 @@ import { FUNCTION_BLOCK_DISPLAY_CODE_KEY, type VariableResolver, } from '@/executor/variables/resolver' +import { createAgentStreamPump } from '@/providers/stream-pump' import type { SerializedBlock } from '@/serializer/types' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' @@ -176,6 +178,7 @@ export class BlockExecutor { } cleanupSelfReference?.() + let streamingPartialOutput: Record | undefined try { const output = handler.executeWithNode ? await handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata) @@ -188,21 +191,24 @@ export class BlockExecutor { if (isStreamingExecution) { const streamingExec = output as StreamingExecution - // The stream must still be drained to populate `execution.output`, but - // forwarding raw chunks to the client (or persisting them to memory) - // before redaction would leak PII. When block-output redaction is on we - // drain in buffer-only mode (no `onStream`, content masked before it's - // stored); the masked final output reaches the client via block-complete. - if (ctx.onStream) { + // Always drain via the agent stream pump (tokens/cost/timing callbacks), + // even with no `onStream`. When block-output redaction is on we do not + // live-forward chunks; content is masked before persist and the masked + // final output reaches the client via block-complete. + try { await this.handleStreamingExecution( ctx, node, block, streamingExec, resolvedInputs, - normalizeStringArray(ctx.selectedOutputs), - !ctx.piiBlockOutputRedaction?.enabled + normalizeStringArray(ctx.selectedOutputs) ) + } catch (streamError) { + // Timeout / drain failures may still have projected answer text — keep it + // for the failed block output so logs match what the client already saw. + streamingPartialOutput = streamingExec.execution?.output + throw streamError } normalizedOutput = this.normalizeOutput( @@ -315,7 +321,8 @@ export class BlockExecutor { blockLog, inputsForLog, isSentinel, - 'execution' + 'execution', + streamingPartialOutput ) } } @@ -372,7 +379,8 @@ export class BlockExecutor { blockLog: BlockLog | undefined, inputsForLog: Record, isSentinel: boolean, - phase: 'input_resolution' | 'execution' + phase: 'input_resolution' | 'execution', + streamingPartialOutput?: Record ): Promise { const endedAt = new Date().toISOString() const duration = performance.now() - startTime @@ -383,10 +391,65 @@ export class BlockExecutor { ? inputsForLog : ((block.config?.params as Record | undefined) ?? {}) + // Routine user Stop on Agent streams: don't paint a failed agent block + // (workflow is already cancelled). Timeouts abort with reason `'timeout'`. + // Non-agent blocks (HTTP, Function, etc.) still fail normally on AbortError + // so logs don't show a green empty success. + const isAbort = + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + const isTimeout = isTimeoutAbortReason(ctx.abortSignal?.reason) + const isAgentBlock = block.metadata?.id === BlockType.AGENT + if (isAbort && !isTimeout && ctx.abortSignal?.aborted && isAgentBlock) { + const softOutput: NormalizedBlockOutput = { + content: '', + } + + this.setNodeOutput(node, softOutput, duration) + + if (blockLog) { + blockLog.endedAt = endedAt + blockLog.durationMs = duration + blockLog.success = true + blockLog.error = undefined + blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) + } + + this.execLogger.info('Block stream aborted by client; soft-completing', { + blockId: node.id, + blockType: block.metadata?.id, + }) + + if (!isSentinel && blockLog) { + this.fireBlockCompleteCallback( + blockStartPromise, + ctx, + node, + block, + this.sanitizeInputsForLog(input, block.metadata?.id), + filterOutputForLog(block.metadata?.id || '', softOutput, { block }), + duration, + blockLog.startedAt, + blockLog.executionOrder, + blockLog.endedAt + ) + } + + return softOutput + } + const errorOutput: NormalizedBlockOutput = { error: errorMessage, } + // Keep any answer text already drained before timeout/failure so logs match + // what was projected to the client. + const partialContent = streamingPartialOutput?.content + if (typeof partialContent === 'string' && partialContent) { + errorOutput.content = partialContent + } + if (ChildWorkflowError.isChildWorkflowError(error)) { errorOutput.childWorkflowName = error.childWorkflowName if (error.childWorkflowSnapshotId) { @@ -774,119 +837,125 @@ export class BlockExecutor { block: SerializedBlock, streamingExec: StreamingExecution, resolvedInputs: Record, - selectedOutputs: string[], - forwardToClient = true + selectedOutputs: string[] ): Promise { const blockId = node.id + const piiEnabled = Boolean(ctx.piiBlockOutputRedaction?.enabled) + // Live-forward only when a client stream exists and PII redaction is off. + const forwardToClient = Boolean(ctx.onStream) && !piiEnabled const responseFormat = resolvedInputs?.responseFormat ?? (block.config?.params as Record | undefined)?.responseFormat ?? (block.config as Record | undefined)?.responseFormat - const sourceReader = streamingExec.stream.getReader() - const decoder = new TextDecoder() - const accumulated: string[] = [] - let drainError: unknown - let sourceFullyDrained = false - - if (forwardToClient) { - const clientSource = new ReadableStream({ - async pull(controller) { - try { - const { done, value } = await sourceReader.read() - if (done) { - const tail = decoder.decode() - if (tail) accumulated.push(tail) - sourceFullyDrained = true - controller.close() - return - } - accumulated.push(decoder.decode(value, { stream: true })) - controller.enqueue(value) - } catch (error) { - drainError = error - controller.error(error) - } - }, - async cancel(reason) { - try { - await sourceReader.cancel(reason) - } catch {} - }, - }) + const streamFormat = streamingExec.streamFormat ?? 'text' + const pump = createAgentStreamPump({ + source: streamingExec.stream, + streamFormat, + // No live consumer → sink-mode so we never buffer into an unread text stream. + sinkMode: !forwardToClient, + abortSignal: ctx.abortSignal, + }) + + let onStreamPromise: Promise | undefined + let processedClientStream: ReadableStream | undefined - const processedClientStream = streamingResponseFormatProcessor.processStream( - clientSource, + if (forwardToClient && ctx.onStream && pump.textStream) { + processedClientStream = streamingResponseFormatProcessor.processStream( + pump.textStream, blockId, selectedOutputs, responseFormat ) - try { - await ctx.onStream?.({ + // Start onStream without awaiting so a sync `subscribe(sink)` can run before + // the first provider pull, then read the projected text stream concurrently + // with `pump.run()`. + onStreamPromise = ctx + .onStream({ + ...streamingExec, stream: processedClientStream, - execution: streamingExec.execution, + streamFormat: 'text', + subscribe: pump.subscribe, + // processStream returns the input stream identity when no + // response-format extraction applies. + clientStreamTransformed: processedClientStream !== pump.textStream, }) - } catch (error) { - this.execLogger.error('Error in onStream callback', { blockId, error }) - await processedClientStream.cancel().catch(() => {}) - } finally { - try { - sourceReader.releaseLock() - } catch {} + .catch(async (error) => { + this.execLogger.error('Error in onStream callback', { blockId, error }) + await processedClientStream?.cancel().catch(() => {}) + }) + } + + let pumpResult + try { + pumpResult = await pump.run() + } catch (error) { + this.execLogger.error('Error reading stream for block', { blockId, error }) + if (onStreamPromise) { + await onStreamPromise.catch(() => {}) } - } else { - // Buffer-only drain: consume the source so `execution.output` is complete, - // but never forward raw chunks to the client (block-output redaction is on). - try { - while (true) { - const { done, value } = await sourceReader.read() - if (done) { - const tail = decoder.decode() - if (tail) accumulated.push(tail) - sourceFullyDrained = true - break - } - accumulated.push(decoder.decode(value, { stream: true })) - } - } catch (error) { - drainError = error - } finally { - try { - sourceReader.releaseLock() - } catch {} + throw error instanceof Error ? error : new Error(String(error)) + } + + if (onStreamPromise) { + await onStreamPromise + } + + // Timeout still fails the block, but keep any drained answer text so logs + // match what was already projected to the client before the deadline. + // User Stop soft-completes below so logs don't show a scary red agent block + // for a routine cancel (workflow status remains `cancelled` via abort). + if (pumpResult.cancelled && pumpResult.cancelReason === 'timeout') { + const truncated = pumpResult.answerText + if (truncated && streamingExec.execution?.output) { + streamingExec.execution.output.content = truncated } + this.execLogger.warn('Stream timed out; persisting drained answer before failing block', { + blockId, + hasContent: Boolean(truncated), + }) + throw new DOMException('Provider request timed out', 'AbortError') } - if (drainError) { - this.execLogger.error('Error reading stream for block', { blockId, error: drainError }) + // Provider onComplete may have attached thinking to timing segments during drain. + // Under PII redaction, never retain raw thinking in traces. + if (piiEnabled) { + stripThinkingContentFromOutput(streamingExec.execution?.output) + } + + // User/unknown cancel: persist truncated answer when present, then return. + if (pumpResult.cancelled) { + const truncated = pumpResult.answerText + if (truncated && streamingExec.execution?.output) { + streamingExec.execution.output.content = truncated + } + this.execLogger.info('Stream cancelled by client; soft-completing agent block', { + blockId, + cancelReason: pumpResult.cancelReason, + hasContent: Boolean(truncated), + }) return } - // If the onStream consumer exited before the source drained (e.g. it caught - // an internal error and returned normally), `accumulated` holds a truncated - // response. Persisting that to memory or setting it as the block output - // would corrupt downstream state — skip and log instead. - if (!sourceFullyDrained) { + // If the pump did not fully drain (should be rare when not cancelled), skip + // persistence of potentially truncated answer text. + if (!pumpResult.fullyDrained) { this.execLogger.warn( 'Stream consumer exited before source drained; skipping content persistence', - { - blockId, - } + { blockId } ) return } - let fullContent = accumulated.join('') + let fullContent = pumpResult.answerText if (!fullContent) { return } - if (!forwardToClient && ctx.piiBlockOutputRedaction?.enabled) { - // Mask before the content is written to `execution.output` or persisted to - // memory via `onFullContent`, so the streamed agent response can't leak PII - // through either path. The block-output redaction below is then idempotent. + if (piiEnabled && ctx.piiBlockOutputRedaction) { + // Mask before writing to `execution.output` or `onFullContent`. fullContent = await redactObjectStrings(fullContent, { entityTypes: ctx.piiBlockOutputRedaction.entityTypes, language: ctx.piiBlockOutputRedaction.language, @@ -931,3 +1000,16 @@ export class BlockExecutor { } } } + +/** Removes retained thinking from provider timing segments (PII safe default). */ +function stripThinkingContentFromOutput(output: unknown): void { + if (!output || typeof output !== 'object') return + const providerTiming = (output as { providerTiming?: { timeSegments?: unknown } }).providerTiming + const segments = providerTiming?.timeSegments + if (!Array.isArray(segments)) return + for (const segment of segments) { + if (segment && typeof segment === 'object' && 'thinkingContent' in segment) { + ;(segment as { thinkingContent?: string }).thinkingContent = undefined + } + } +} diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index cf3198e9c6f..c8630a77748 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -189,4 +189,30 @@ describe('serializePauseSnapshot', () => { expect(serialized.metadata.billingAttribution).toEqual(billingAttribution) }) + + it('preserves independent chat event policies across pause and resume', () => { + const context = createContext({ + metadata: { + ...createContext().metadata, + includeThinking: true, + includeToolCalls: false, + executionMode: 'stream', + }, + }) + + const snapshot = serializePauseSnapshot(context, ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.includeThinking).toBe(true) + expect(serialized.metadata.includeToolCalls).toBe(false) + expect(serialized.metadata.executionMode).toBe('stream') + }) + + it('omits chat event policies when the live run did not enable them', () => { + const snapshot = serializePauseSnapshot(createContext(), ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.includeThinking).toBeUndefined() + expect(serialized.metadata.includeToolCalls).toBeUndefined() + }) }) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index e26efc9d1df..d6025fcc0fd 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -252,6 +252,15 @@ export function serializePauseSnapshot( startTime: metadataFromContext?.startTime ?? new Date().toISOString(), isClientSession: metadataFromContext?.isClientSession, executionMode: metadataFromContext?.executionMode, + /** Preserve deployed-chat thinking gate across HITL pause/resume. */ + includeThinking: metadataFromContext?.includeThinking === true ? true : undefined, + /** Preserve false as distinct from a legacy snapshot with no independent tool policy. */ + includeToolCalls: + typeof metadataFromContext?.includeToolCalls === 'boolean' + ? metadataFromContext.includeToolCalls + : undefined, + /** Preserve the run-level agent-events opt-in across HITL pause/resume. */ + agentEvents: metadataFromContext?.agentEvents === true ? true : undefined, } const snapshot = new ExecutionSnapshot( diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index d114897721e..02723e9fcdf 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -47,6 +47,25 @@ export interface ExecutionMetadata { callChain?: string[] correlation?: AsyncExecutionCorrelation executionMode?: 'sync' | 'stream' | 'async' + /** + * Deployed-chat thinking policy half of the SSE dual gate. Persisted so HITL + * resume can re-enable thinking frames without hardcoding false. + */ + includeThinking?: boolean + /** + * Deployed-chat tool lifecycle policy half of the SSE dual gate. Persisted so + * HITL resume can re-enable tool frames without coupling them to thinking. + * Explicit false distinguishes new snapshots from legacy snapshots that + * inherit the thinking policy. + */ + includeToolCalls?: boolean + /** + * Run-level agent-events opt-in. True only on surfaces that consume thinking + * and tool lifecycle events (canvas Run, dual-gated public chat). Enables the + * live streaming tool loops and provider thinking-summary requests; when + * unset, providers behave exactly as they did before agent events existed. + */ + agentEvents?: boolean } export interface SerializableExecutionState { diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 8ef8c310b16..17fa2f967d6 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1875,6 +1875,87 @@ describe('AgentBlockHandler', () => { expect(providerCallArgs.billingAttribution).toEqual(billingAttribution) }) + it('forwards streaming and agent events on opted-in runs', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Stream this', + apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'search_files', + schema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + params: { + serverId: 'mcp-search-server', + toolName: 'search_files', + serverName: 'search', + }, + usageControl: 'auto' as const, + }, + ], + } + + const streamingContext = { + ...mockContext, + stream: true, + selectedOutputs: ['test-agent-block'], + metadata: { ...mockContext.metadata, agentEvents: true }, + } as ExecutionContext + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(streamingContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1] + expect(providerCallArgs.stream).toBe(true) + expect(providerCallArgs.agentEvents).toBe(true) + }) + + it('forwards ordinary streaming without exposing agent events', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Stream this', + apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'search_files', + schema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + params: { + serverId: 'mcp-search-server', + toolName: 'search_files', + serverName: 'search', + }, + usageControl: 'auto' as const, + }, + ], + } + + const streamingContext = { + ...mockContext, + stream: true, + selectedOutputs: ['test-agent-block'], + } as ExecutionContext + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(streamingContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1] + expect(providerCallArgs.stream).toBe(true) + expect(providerCallArgs.agentEvents).toBe(false) + }) + it('should handle multiple MCP tools from the same server efficiently', async () => { const fetchCalls: any[] = [] @@ -2290,4 +2371,38 @@ describe('AgentBlockHandler', () => { }) }) }) + + describe('wrapStreamForMemoryPersistence envelope', () => { + it('preserves streamFormat and subscribe via object spread', () => { + const handler = new AgentBlockHandler() + const subscribe = vi.fn() + const streamingExec: StreamingExecution = { + stream: new ReadableStream(), + streamFormat: 'agent-events-v1', + subscribe, + execution: { + success: true, + output: { content: '' }, + logs: [], + metadata: { startTime: '', endTime: '', duration: 0 }, + }, + } + + const wrapped = ( + handler as unknown as { + wrapStreamForMemoryPersistence: ( + ctx: ExecutionContext, + inputs: Record, + exec: StreamingExecution + ) => StreamingExecution + } + ).wrapStreamForMemoryPersistence({} as ExecutionContext, {}, streamingExec) + + expect(wrapped.streamFormat).toBe('agent-events-v1') + expect(wrapped.subscribe).toBe(subscribe) + expect(wrapped.stream).toBe(streamingExec.stream) + expect(wrapped.execution).toBe(streamingExec.execution) + expect(typeof wrapped.onFullContent).toBe('function') + }) + }) }) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index a09d70e3f31..fc721b990f2 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -954,7 +954,10 @@ export class AgentBlockHandler implements BlockHandler { reasoningEffort: inputs.reasoningEffort, verbosity: inputs.verbosity, thinkingLevel: inputs.thinkingLevel, + promptCaching: inputs.promptCaching === true, previousInteractionId: inputs.previousInteractionId, + /** Agent-events remains the opt-in for exposing thinking and tool lifecycle events. */ + agentEvents: streaming && ctx.metadata?.agentEvents === true, } } @@ -1028,7 +1031,11 @@ export class AgentBlockHandler implements BlockHandler { reasoningEffort: providerRequest.reasoningEffort, verbosity: providerRequest.verbosity, thinkingLevel: providerRequest.thinkingLevel, + promptCaching: providerRequest.promptCaching, + // Stable per-block identity; providers use it to route cache lookups. + blockId: block.id, previousInteractionId: providerRequest.previousInteractionId, + agentEvents: providerRequest.agentEvents, abortSignal: ctx.abortSignal, }) @@ -1088,8 +1095,7 @@ export class AgentBlockHandler implements BlockHandler { streamingExec: StreamingExecution ): StreamingExecution { return { - stream: streamingExec.stream, - execution: streamingExec.execution, + ...streamingExec, onFullContent: async (content: string) => { if (!content.trim()) return try { diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts index fb9103da2bc..bfc68245b28 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.test.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -3,20 +3,25 @@ */ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveSkillContent } from './skills-resolver' +import { + resolveSkillContent, + resolveSkillContentById, + resolveSkillMetadata, +} from './skills-resolver' -// resolveSkillContent is the shared resolver invoked when a workflow agent block -// calls load_skill. -describe('resolveSkillContent', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) +// resolveSkillContent is the shared resolver invoked when a workflow agent +// block calls load_skill. Skill editors gate editing only — resolution never +// blocks on the acting user. +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() +}) - afterAll(() => { - resetDbChainMock() - }) +afterAll(() => { + resetDbChainMock() +}) +describe('resolveSkillContent', () => { it('returns null without a skill name or workspace', async () => { expect(await resolveSkillContent('', 'ws-1')).toBeNull() expect(await resolveSkillContent('x', '')).toBeNull() @@ -29,7 +34,7 @@ describe('resolveSkillContent', () => { }) it('resolves a workspace user skill by name', async () => { - queueTableRows(schemaMock.skill, [{ content: '# Playbook', name: 'posthog-playbook' }]) + queueTableRows(schemaMock.skill, [{ content: '# Playbook' }]) expect(await resolveSkillContent('posthog-playbook', 'ws-1')).toBe('# Playbook') }) @@ -37,3 +42,30 @@ describe('resolveSkillContent', () => { expect(await resolveSkillContent('missing', 'ws-1')).toBeNull() }) }) + +describe('resolveSkillContentById', () => { + it('resolves a workspace skill by id', async () => { + queueTableRows(schemaMock.skill, [{ content: '# Body', name: 'my-skill' }]) + expect(await resolveSkillContentById('sk-1', 'ws-1')).toEqual({ + name: 'my-skill', + content: '# Body', + }) + }) + + it('returns null when the skill does not exist in the workspace', async () => { + expect(await resolveSkillContentById('missing', 'ws-1')).toBeNull() + }) +}) + +describe('resolveSkillMetadata', () => { + it('returns every attached skill in the workspace', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-1', name: 'a', description: 'A' }, + { id: 'sk-2', name: 'b', description: 'B' }, + ]) + + const metadata = await resolveSkillMetadata([{ skillId: 'sk-1' }, { skillId: 'sk-2' }], 'ws-1') + + expect(metadata.map((m) => m.name)).toEqual(['a', 'b']) + }) +}) diff --git a/apps/sim/executor/handlers/agent/skills-resolver.ts b/apps/sim/executor/handlers/agent/skills-resolver.ts index b5a145562ee..b14024ec570 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.ts @@ -45,11 +45,11 @@ export async function resolveSkillMetadata( try { const rows = await db - .select({ name: skill.name, description: skill.description }) + .select({ id: skill.id, name: skill.name, description: skill.description }) .from(skill) .where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, dbSkillIds))) - return [...metadata, ...rows] + return [...metadata, ...rows.map((row) => ({ name: row.name, description: row.description }))] } catch (error) { logger.error('Failed to resolve skill metadata', { error, dbSkillIds, workspaceId }) return metadata @@ -71,7 +71,7 @@ export async function resolveSkillContent( try { const rows = await db - .select({ content: skill.content, name: skill.name }) + .select({ content: skill.content }) .from(skill) .where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, skillName))) .limit(1) @@ -109,7 +109,7 @@ export async function resolveSkillContentById( return null } - return rows[0] + return { name: rows[0].name, content: rows[0].content } } catch (error) { logger.error('Failed to resolve skill content', { error, skillId, workspaceId }) return null diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 39329739a43..4c311b190dd 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -39,6 +39,7 @@ export interface AgentInputs { reasoningEffort?: string verbosity?: string thinkingLevel?: string + promptCaching?: boolean files?: unknown } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 5afc6b640df..b992730f0cc 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -163,6 +163,34 @@ describe('EvaluatorBlockHandler', () => { }) }) + it('bills the cost the provider proxy decided rather than recomputing it', async () => { + // The proxy already resolved key provenance and the margin; recomputing + // here would re-charge a BYOK caller the proxy correctly zeroed. + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + content: JSON.stringify({ score1: 5, score2: 8 }), + model: 'mock-model', + tokens: { input: 50, output: 10, total: 60 }, + cost: { input: 0.001, output: 0.0005, total: 0.0015 }, + timing: { total: 200 }, + }), + }) + ) + + const result = await handler.execute(mockContext, mockBlock, { + content: 'This is the content to evaluate.', + }) + + expect((result as { cost: unknown }).cost).toEqual({ + input: 0.001, + output: 0.0005, + total: 0.0015, + }) + }) + it('should process JSON string content correctly', async () => { const contentObj = { text: 'Evaluate this JSON.', value: 42 } const inputs = { diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 3ab07bc3653..1f96fff1d6f 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -6,7 +6,8 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' -import { calculateCost, getProviderFromModel } from '@/providers/utils' +import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('EvaluatorBlockHandler') @@ -154,7 +155,7 @@ export class EvaluatorBlockHandler implements BlockHandler { const outputTokens = result.tokens?.output || result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION - const costCalculation = calculateCost(result.model, inputTokens, outputTokens, false) + const cost = resolveProxiedModelCost(result.cost) return { content: inputs.content, @@ -165,9 +166,9 @@ export class EvaluatorBlockHandler implements BlockHandler { total: result.tokens?.total || DEFAULTS.TOKENS.TOTAL, }, cost: { - input: costCalculation.input, - output: costCalculation.output, - total: costCalculation.total, + input: cost.input, + output: cost.output, + total: cost.total, }, ...metricScores, } diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index ada9859cd39..8107c62bcf3 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -13,7 +13,7 @@ const { mockRun, mockReadFile, mockWriteFile, mockExecuteTool, mockProviderEnvVa }) ) -vi.mock('@/lib/execution/e2b', () => ({ +vi.mock('@/lib/execution/remote-sandbox', () => ({ withPiSandbox: (fn: (runner: unknown) => unknown) => fn({ run: mockRun, readFile: mockReadFile, writeFile: mockWriteFile }), })) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index ce91e7c957f..5b46820140d 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -15,7 +15,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' -import { withPiSandbox } from '@/lib/execution/e2b' +import { withPiSandbox } from '@/lib/execution/remote-sandbox' import type { PiBackendRun, PiCloudRunParams } from '@/executor/handlers/pi/backend' import { CLONE_TIMEOUT_MS, diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts index d5131ec0403..83e9b308b31 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts @@ -58,7 +58,7 @@ const mockModelRuntime = { removeRuntimeApiKey: mockRemoveRuntimeApiKey, } -vi.mock('@/lib/execution/e2b', () => ({ +vi.mock('@/lib/execution/remote-sandbox', () => ({ withPiSandbox: (fn: (runner: unknown) => unknown) => fn({ run: mockRun, writeFile: mockWriteFile }), })) diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.ts index f051aec4e13..1b7f2bf91e9 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-review-backend.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' -import { withPiSandbox } from '@/lib/execution/e2b' +import { withPiSandbox } from '@/lib/execution/remote-sandbox' import type { PiBackendRun, PiCloudReviewRunParams } from '@/executor/handlers/pi/backend' import { CLOUD_REVIEW_TOOL_NAMES, diff --git a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts b/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts index c2aded09d48..4cf18a9f3a2 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { promisify } from 'node:util' import * as sdk from '@earendil-works/pi-coding-agent' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { PiSandboxRunner } from '@/lib/execution/e2b' +import type { PiSandboxRunner } from '@/lib/execution/remote-sandbox' import { CLOUD_REVIEW_TOOL_NAMES, createCloudReviewTools, diff --git a/apps/sim/executor/handlers/pi/cloud-review-tools.ts b/apps/sim/executor/handlers/pi/cloud-review-tools.ts index eb1e8a15da3..192e049bf6a 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-tools.ts +++ b/apps/sim/executor/handlers/pi/cloud-review-tools.ts @@ -1,6 +1,6 @@ import type { ToolDefinition } from '@earendil-works/pi-coding-agent' import { Type } from 'typebox' -import type { PiSandboxRunner } from '@/lib/execution/e2b' +import type { PiSandboxRunner } from '@/lib/execution/remote-sandbox' import { REVIEW_TOOLS_SCRIPT } from '@/executor/handlers/pi/cloud-review-tools-script' import { raceAbort } from '@/executor/handlers/pi/cloud-shared' import type { PiSdk } from '@/executor/handlers/pi/pi-sdk' diff --git a/apps/sim/executor/handlers/pi/context.ts b/apps/sim/executor/handlers/pi/context.ts index dfabfd67a5c..b6e41e74e50 100644 --- a/apps/sim/executor/handlers/pi/context.ts +++ b/apps/sim/executor/handlers/pi/context.ts @@ -26,7 +26,9 @@ function isMemoryEnabled(config: PiMemoryConfig): boolean { return !!config.memoryType && config.memoryType !== 'none' } -/** Resolves selected skill inputs to full `{ name, content }` entries for Pi. */ +/** + * Resolves selected skill inputs to full `{ name, content }` entries for Pi. + */ export async function resolvePiSkills( skillInputs: unknown, workspaceId: string | undefined @@ -36,15 +38,8 @@ export async function resolvePiSkills( const skills: PiSkill[] = [] for (const input of skillInputs as SkillInput[]) { if (!input?.skillId) continue - try { - const resolved = await resolveSkillContentById(input.skillId, workspaceId) - if (resolved) skills.push({ name: resolved.name, content: resolved.content }) - } catch (error) { - logger.warn('Failed to resolve skill for Pi', { - skillId: input.skillId, - error: getErrorMessage(error), - }) - } + const resolved = await resolveSkillContentById(input.skillId, workspaceId) + if (resolved) skills.push({ name: resolved.name, content: resolved.content }) } return skills } diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index cb1c6bbcde2..90f8f25befa 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -51,13 +51,21 @@ describe('computePiCost', () => { }) it('returns zero cost for BYOK keys without billing', () => { - expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 }) + expect(computePiCost('claude', 100, 200, true)).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) expect(mockCalculateCost).not.toHaveBeenCalled() }) it('returns zero cost for non-billable models', () => { mockShouldBill.mockReturnValue(false) - expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 }) + expect(computePiCost('local-model', 100, 200, false)).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) expect(mockCalculateCost).not.toHaveBeenCalled() }) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index 870686ac3ee..14ebbcc148b 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -10,14 +10,13 @@ import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent' import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok' -import { getCostMultiplier } from '@/lib/core/config/env-flags' +import { calculateBillableModelCost } from '@/providers/cost-policy' import type { PiSupportedProvider } from '@/providers/pi-provider-configs' import { getPiProviderApiKeyEnvVar, getPiWorkspaceBYOKProviderId, isPiSupportedProvider, } from '@/providers/pi-providers' -import { calculateCost, shouldBillModelUsage } from '@/providers/utils' /** Resolved provider key and BYOK flag for a Pi run. */ interface PiKeyResolution { @@ -74,11 +73,7 @@ export function computePiCost( outputTokens: number, isBYOK: boolean ) { - if (isBYOK || !shouldBillModelUsage(model)) { - return { input: 0, output: 0, total: 0 } - } - const multiplier = getCostMultiplier() - return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier) + return calculateBillableModelCost(model, inputTokens, outputTokens, { isBYOK }) } /** diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index a46637c70bd..ebbaa2660d7 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -135,7 +135,6 @@ export class PiBlockHandler implements BlockHandler { } return this.runPi(ctx, block, runCloudReviewPi, params) } - const memoryConfig: PiMemoryConfig = { memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'], conversationId: asOptString(inputs.conversationId), diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 299eaf6e8dc..3c57fe2de8f 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -204,6 +204,34 @@ describe('RouterBlockHandler', () => { }) }) + it('bills the cost the provider proxy decided rather than recomputing it', async () => { + // The proxy already resolved key provenance and the margin; recomputing + // here would re-charge a BYOK caller the proxy correctly zeroed. + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + content: 'target-block-1', + model: 'mock-model', + tokens: { input: 100, output: 5, total: 105 }, + cost: { input: 0.004, output: 0.002, total: 0.006 }, + timing: { total: 300 }, + }), + }) + ) + + const result = await handler.execute(mockContext, mockBlock, { + prompt: 'Choose the best option.', + }) + + expect((result as { cost: unknown }).cost).toEqual({ + input: 0.004, + output: 0.002, + total: 0.006, + }) + }) + it('should throw error if target block is missing', async () => { const inputs = { prompt: 'Test' } mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2] diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 12042918b25..55184c40611 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -13,7 +13,8 @@ import { import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' -import { calculateCost, getProviderFromModel } from '@/providers/utils' +import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('RouterBlockHandler') @@ -145,12 +146,7 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = calculateCost( - result.model, - tokens.input || DEFAULTS.TOKENS.PROMPT, - tokens.output || DEFAULTS.TOKENS.COMPLETION, - false - ) + const cost = resolveProxiedModelCost(result.cost) return { prompt: inputs.prompt, @@ -331,12 +327,7 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = calculateCost( - result.model, - tokens.input || DEFAULTS.TOKENS.PROMPT, - tokens.output || DEFAULTS.TOKENS.COMPLETION, - false - ) + const cost = resolveProxiedModelCost(result.cost) return { context: inputs.context, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index bddbd2fc763..653d1c51d8e 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -10,6 +10,7 @@ import type { SerializableExecutionState, } from '@/executor/execution/types' import type { RunFromBlockContext } from '@/executor/utils/run-from-block' +import type { AgentStreamSink, UnsubscribeAgentStreamSink } from '@/providers/stream-events' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { SubflowType } from '@/stores/workflows/workflow/types' @@ -184,7 +185,7 @@ export interface BlockToolCall { error?: string arguments?: Record input?: Record - result?: Record + result?: unknown output?: Record } @@ -314,6 +315,11 @@ interface ExecutionMetadata { resumeFromSnapshot?: boolean resumeTerminalNoop?: boolean executionMode?: 'sync' | 'stream' | 'async' + /** + * Run-level agent-events opt-in (see the snapshot ExecutionMetadata). + * Gates streaming tool loops and provider thinking-summary requests. + */ + agentEvents?: boolean } export interface BlockState { @@ -522,7 +528,32 @@ export interface ExecutionResult { } export interface StreamingExecution { + /** + * Provider stream payload. Format is declared by {@link streamFormat}: + * - `'text'` (default): UTF-8 answer bytes (`ReadableStream`) + * - `'agent-events-v1'`: in-process `ReadableStream` of `AgentStreamEvent` objects + * + * Never sniff the payload; always read {@link streamFormat}. + * After the executor pump, {@link stream} is always projected UTF-8 answer text. + */ stream: ReadableStream + /** + * Discriminator for {@link stream}. Defaults to `'text'` when omitted so + * existing providers remain byte-stream consumers without changes. + */ + streamFormat?: 'text' | 'agent-events-v1' + /** + * Optional sink subscription installed synchronously during `onStream` before + * the executor pump starts draining. Late subscribers receive future events only. + */ + subscribe?: (sink: AgentStreamSink) => UnsubscribeAgentStreamSink + /** + * True when {@link stream} is a response-format projection (selected JSON + * fields extracted from structured output) rather than raw answer text. Sink + * `text_delta` events then do NOT match the byte stream, so consumers must + * keep sourcing answer text from {@link stream} instead of the sink. + */ + clientStreamTransformed?: boolean execution: ExecutionResult & { isStreaming?: boolean } /** * Invoked with the assembled response text after the stream drains. Lets agent diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 1c1da29faa1..b0e4c37a4ab 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -175,6 +175,10 @@ export interface ChatFormData { emails: string[] welcomeMessage: string selectedOutputBlocks: string[] + /** When true, thinking may be streamed to clients that opt into agent-events-v1. Default false. */ + includeThinking: boolean + /** When true, tool lifecycle may be streamed to opted-in clients. Default false. */ + includeToolCalls: boolean } /** @@ -263,6 +267,8 @@ function buildChatPayload( allowedEmails: formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, + includeThinking: formData.includeThinking, + includeToolCalls: formData.includeToolCalls, } } diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts index e2bd5a1265f..6817f6b6249 100644 --- a/apps/sim/hooks/queries/skills.ts +++ b/apps/sim/hooks/queries/skills.ts @@ -3,14 +3,19 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import { requestJson } from '@/lib/api/client/request' import { deleteSkillContract, + listSkillMembersContract, listSkillsContract, + removeSkillMemberContract, type Skill, + type SkillEditor, + upsertSkillMemberContract, upsertSkillsContract, } from '@/lib/api/contracts' const logger = createLogger('SkillsQueries') export const SKILL_LIST_STALE_TIME = 60 * 1000 +export const SKILL_MEMBER_LIST_STALE_TIME = 30 * 1000 export type SkillDefinition = Skill @@ -21,6 +26,7 @@ export const skillsKeys = { all: ['skills'] as const, lists: () => [...skillsKeys.all, 'list'] as const, list: (workspaceId: string) => [...skillsKeys.lists(), workspaceId] as const, + members: (skillId?: string) => [...skillsKeys.all, 'members', skillId ?? ''] as const, } /** @@ -70,7 +76,13 @@ export function useCreateSkill() { const { data } = await requestJson(upsertSkillsContract, { body: { - skills: [{ name: s.name, description: s.description, content: s.content }], + skills: [ + { + name: s.name, + description: s.description, + content: s.content, + }, + ], workspaceId, }, }) @@ -114,25 +126,11 @@ export function useUpdateSkill() { mutationFn: async ({ workspaceId, skillId, updates }: UpdateSkillParams) => { logger.info(`Updating skill: ${skillId} in workspace ${workspaceId}`) - const currentSkills = queryClient.getQueryData( - skillsKeys.list(workspaceId) - ) - const currentSkill = currentSkills?.find((s) => s.id === skillId) - - if (!currentSkill) { - throw new Error('Skill not found') - } - + // Updates are partial on the wire — omitted fields are preserved + // server-side, so nothing is re-sent from a possibly-stale cache. const { data } = await requestJson(upsertSkillsContract, { body: { - skills: [ - { - id: skillId, - name: updates.name ?? currentSkill.name, - description: updates.description ?? currentSkill.description, - content: updates.content ?? currentSkill.content, - }, - ], + skills: [{ id: skillId, ...updates }], workspaceId, }, }) @@ -172,6 +170,7 @@ export function useUpdateSkill() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) }) }, }) } @@ -224,3 +223,88 @@ export function useDeleteSkill() { }, }) } + +/** + * Fetch the editor roster for a skill (explicit editors plus derived workspace + * admins). Built-in skills have no editors — callers should not enable this + * for readOnly skills. + */ +export function useSkillMembers(skillId?: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: skillsKeys.members(skillId), + queryFn: async ({ signal }) => { + if (!skillId) return [] + const data = await requestJson(listSkillMembersContract, { + params: { id: skillId }, + signal, + }) + return data.editors + }, + enabled: Boolean(skillId) && (options?.enabled ?? true), + staleTime: SKILL_MEMBER_LIST_STALE_TIME, + }) +} + +interface UpsertSkillMemberParams { + skillId: string + workspaceId: string + userId: string +} + +export function useUpsertSkillMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ skillId, userId }: UpsertSkillMemberParams) => { + return requestJson(upsertSkillMemberContract, { + params: { id: skillId }, + body: { userId }, + }) + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) }) + queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) + }, + }) +} + +interface RemoveSkillMemberParams { + skillId: string + workspaceId: string + userId: string +} + +export function useRemoveSkillMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ skillId, userId }: RemoveSkillMemberParams) => { + return requestJson(removeSkillMemberContract, { + params: { id: skillId }, + query: { userId }, + }) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: skillsKeys.members(variables.skillId) }) + const previousEditors = queryClient.getQueryData( + skillsKeys.members(variables.skillId) + ) + if (previousEditors) { + queryClient.setQueryData( + skillsKeys.members(variables.skillId), + previousEditors.filter((editor) => editor.userId !== variables.userId) + ) + } + return { previousEditors } + }, + onError: (_err, variables, context) => { + if (context?.previousEditors) { + queryClient.setQueryData(skillsKeys.members(variables.skillId), context.previousEditors) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) }) + queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) + }, + }) +} diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index e0c752bb446..ca73695d24f 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -153,6 +153,7 @@ export function useCollaborativeWorkflow() { onSubblockUpdate, onVariableUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, @@ -639,6 +640,22 @@ export function useCollaborativeWorkflow() { } } + const handleAccessRevoked = (data: any) => { + const { workflowId } = data + logger.warn(`Access to workflow ${workflowId} has been revoked`) + + if (activeWorkflowId === workflowId) { + logger.info( + `Access to currently active workflow ${workflowId} was revoked, stopping collaborative operations` + ) + + const currentUserId = session?.user?.id || 'unknown' + useUndoRedoStore.getState().clear(workflowId, currentUserId) + + isApplyingRemoteChange.current = false + } + } + const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise => { const reloadSequence = (reloadSequencesRef.current[workflowId] ?? 0) + 1 reloadSequencesRef.current[workflowId] = reloadSequence @@ -913,6 +930,7 @@ export function useCollaborativeWorkflow() { onSubblockUpdate(handleSubblockUpdate) onVariableUpdate(handleVariableUpdate) onWorkflowDeleted(handleWorkflowDeleted) + onAccessRevoked(handleAccessRevoked) onWorkflowReverted(handleWorkflowReverted) onWorkflowUpdated(handleWorkflowUpdated) onWorkflowDeployed(handleWorkflowDeployed) @@ -935,6 +953,7 @@ export function useCollaborativeWorkflow() { onSubblockUpdate, onVariableUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, diff --git a/apps/sim/hooks/use-execution-stream.test.ts b/apps/sim/hooks/use-execution-stream.test.ts index f38f028c805..4ad6425ce13 100644 --- a/apps/sim/hooks/use-execution-stream.test.ts +++ b/apps/sim/hooks/use-execution-stream.test.ts @@ -53,6 +53,61 @@ describe('processSSEStream', () => { expect(order).toEqual(['handler:start', 'handler:end', 'event-id']) }) + it('routes stream:thinking and stream:tool without requiring event ids', async () => { + const onStreamThinking = vi.fn() + const onStreamTool = vi.fn() + const onStreamChunk = vi.fn() + const onEventId = vi.fn() + + const events: ExecutionEvent[] = [ + { + type: 'stream:thinking', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', text: 'reasoning ' }, + }, + { + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { + blockId: 'agent-1', + phase: 'start', + id: 'tool_1', + name: 'http_request', + }, + }, + { + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', chunk: 'answer' }, + }, + ] + + await processSSEStream( + streamEvents(events).getReader(), + { onStreamThinking, onStreamTool, onStreamChunk, onEventId }, + 'test' + ) + + expect(onStreamThinking).toHaveBeenCalledWith({ + blockId: 'agent-1', + text: 'reasoning ', + }) + expect(onStreamTool).toHaveBeenCalledWith({ + blockId: 'agent-1', + phase: 'start', + id: 'tool_1', + name: 'http_request', + }) + expect(onStreamChunk).toHaveBeenCalledWith({ blockId: 'agent-1', chunk: 'answer' }) + expect(onEventId).not.toHaveBeenCalled() + }) + it('propagates callback failures without acknowledging the event id', async () => { const event: ExecutionEvent = { type: 'block:started', diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index ffe862c4710..a27c1ab4ce4 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -14,7 +14,10 @@ import type { ExecutionPausedData, ExecutionStartedData, StreamChunkData, + StreamChunkResetData, StreamDoneData, + StreamThinkingData, + StreamToolData, } from '@/lib/workflows/executor/execution-events' import type { SerializableExecutionState } from '@/executor/execution/types' @@ -121,9 +124,18 @@ export async function processSSEStream( case 'stream:chunk': await callbacks.onStreamChunk?.(event.data) break + case 'stream:chunk_reset': + await callbacks.onStreamChunkReset?.(event.data) + break case 'stream:done': await callbacks.onStreamDone?.(event.data) break + case 'stream:thinking': + await callbacks.onStreamThinking?.(event.data) + break + case 'stream:tool': + await callbacks.onStreamTool?.(event.data) + break default: logger.warn('Unknown event type:', (event as any).type) } @@ -164,7 +176,10 @@ export interface ExecutionStreamCallbacks { onBlockError?: (data: BlockErrorData) => void | Promise onBlockChildWorkflowStarted?: (data: BlockChildWorkflowStartedData) => void | Promise onStreamChunk?: (data: StreamChunkData) => void | Promise + onStreamChunkReset?: (data: StreamChunkResetData) => void | Promise onStreamDone?: (data: StreamDoneData) => void | Promise + onStreamThinking?: (data: StreamThinkingData) => void | Promise + onStreamTool?: (data: StreamToolData) => void | Promise onEventId?: (eventId: number) => void | Promise } diff --git a/apps/sim/lib/api/contracts/chats.include-thinking.test.ts b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts new file mode 100644 index 00000000000..d569a9b44e2 --- /dev/null +++ b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createChatBodySchema, + deployedChatConfigSchema, + updateChatBodySchema, +} from '@/lib/api/contracts/chats' +import { chatDetailSchema } from '@/lib/api/contracts/deployments' + +describe('chat agent-event policy contracts', () => { + it('create defaults both policies to false', () => { + const parsed = createChatBodySchema.parse({ + workflowId: 'wf-1', + identifier: 'my-chat', + title: 'Support', + customizations: { + primaryColor: 'var(--brand-hover)', + welcomeMessage: 'Hi', + }, + }) + expect(parsed.includeThinking).toBe(false) + expect(parsed.includeToolCalls).toBe(false) + }) + + it('create accepts independent policy values', () => { + const parsed = createChatBodySchema.parse({ + workflowId: 'wf-1', + identifier: 'my-chat', + title: 'Support', + customizations: { + primaryColor: 'var(--brand-hover)', + welcomeMessage: 'Hi', + }, + includeThinking: true, + includeToolCalls: false, + }) + expect(parsed.includeThinking).toBe(true) + expect(parsed.includeToolCalls).toBe(false) + }) + + it('update accepts independent policy toggles', () => { + expect(updateChatBodySchema.parse({ includeThinking: true }).includeThinking).toBe(true) + expect(updateChatBodySchema.parse({ includeThinking: false }).includeThinking).toBe(false) + expect(updateChatBodySchema.parse({ title: 'x' }).includeThinking).toBeUndefined() + expect(updateChatBodySchema.parse({ includeToolCalls: true }).includeToolCalls).toBe(true) + expect(updateChatBodySchema.parse({ includeToolCalls: false }).includeToolCalls).toBe(false) + expect(updateChatBodySchema.parse({ title: 'x' }).includeToolCalls).toBeUndefined() + }) + + it('chat detail and deployed config expose both policies', () => { + const detail = chatDetailSchema.parse({ + id: 'chat-1', + identifier: 'my-chat', + title: 'Support', + description: '', + authType: 'public', + allowedEmails: [], + outputConfigs: [], + isActive: true, + chatUrl: 'http://localhost/chat/my-chat', + hasPassword: false, + }) + expect(detail.includeThinking).toBe(false) + expect(detail.includeToolCalls).toBe(false) + + const detailOn = chatDetailSchema.parse({ + ...detail, + includeThinking: true, + includeToolCalls: true, + }) + expect(detailOn.includeThinking).toBe(true) + expect(detailOn.includeToolCalls).toBe(true) + + const config = deployedChatConfigSchema.parse({ + id: 'chat-1', + title: 'Support', + description: '', + customizations: {}, + authType: 'public', + }) + expect(config.includeThinking).toBe(false) + expect(config.includeToolCalls).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index d0840bca8a5..528e20c5c28 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -41,6 +41,10 @@ export const createChatBodySchema = z.object({ password: z.string().optional(), allowedEmails: z.array(z.string()).optional().default([]), outputConfigs: z.array(chatOutputConfigSchema).optional().default([]), + /** When true, clients may receive thinking SSE if they also send the protocol header. Default off. */ + includeThinking: z.boolean().optional().default(false), + /** When true, clients may receive tool lifecycle SSE if they also send the protocol header. */ + includeToolCalls: z.boolean().optional().default(false), }) export type CreateChatBody = z.input @@ -58,6 +62,8 @@ export const updateChatBodySchema = z.object({ password: z.string().optional(), allowedEmails: z.array(z.string()).optional(), outputConfigs: z.array(chatOutputConfigSchema).optional(), + includeThinking: z.boolean().optional(), + includeToolCalls: z.boolean().optional(), }) export type UpdateChatBody = z.input @@ -101,6 +107,10 @@ export const deployedChatConfigSchema = z.object({ (value) => value ?? undefined, z.array(deployedChatOutputConfigSchema).optional() ), + /** Policy for thinking SSE; clients still need the X-Sim-Stream-Protocol opt-in. */ + includeThinking: z.preprocess((value) => value ?? false, z.boolean()), + /** Policy for tool lifecycle SSE; clients still need the protocol opt-in. */ + includeToolCalls: z.preprocess((value) => value ?? false, z.boolean()), }) export type DeployedChatConfig = z.output @@ -209,8 +219,13 @@ export const deployedChatPostContract = defineRouteContract({ params: chatIdentifierParamsSchema, body: deployedChatPostBodySchema, response: { - mode: 'json', - schema: deployedChatConfigSchema, + /** + * Message posts return SSE (`text/event-stream`). Auth-only POSTs use + * authenticateDeployedChatContract (JSON). Terminal frames: `final` or one + * `error`, then `[DONE]`. Thinking and tool frames use independent deployment + * policies; both require the protocol header. + */ + mode: 'stream', }, }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index a7daf83b739..db8e82adb6b 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -176,6 +176,8 @@ export const chatDetailSchema = z.object({ }) ) ), + includeThinking: z.preprocess((value) => value ?? false, z.boolean()), + includeToolCalls: z.preprocess((value) => value ?? false, z.boolean()), customizations: z.preprocess( (value) => value ?? undefined, z diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index c3ac2f94e85..ccaebe8547f 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -194,6 +194,51 @@ export const organizationSessionPolicyResponseSchema = z.object({ data: organizationSessionPolicyDataSchema, }) +export const MAX_ORGANIZATION_DOMAINS = 25 + +export const organizationDomainParamsSchema = z.object({ + id: z.string().min(1), + domainId: z.string().min(1), +}) + +export const addOrganizationDomainBodySchema = z.object({ + domain: z.string().min(1, 'Domain is required').max(253, 'Domain is too long'), +}) + +export type AddOrganizationDomainBody = z.input + +export const organizationDomainStatusSchema = z.enum(['pending', 'verified']) + +const organizationDomainSchema = z.object({ + id: z.string(), + domain: z.string(), + status: organizationDomainStatusSchema, + verifiedAt: z.string().nullable(), + /** DNS host the TXT record must live on (e.g. `_sim-challenge.acme.com`). */ + challengeHost: z.string(), + /** Exact TXT record value the org must publish. Null for grandfathered/verified rows. */ + txtRecordValue: z.string().nullable(), +}) + +export type OrganizationDomain = z.output + +const organizationDomainsDataSchema = z.object({ + isEnterprise: z.boolean(), + domains: z.array(organizationDomainSchema), +}) + +export type OrganizationDomains = z.output + +export const listOrganizationDomainsResponseSchema = z.object({ + success: z.boolean(), + data: organizationDomainsDataSchema, +}) + +export const organizationDomainResponseSchema = z.object({ + success: z.boolean(), + data: z.object({ domain: organizationDomainSchema }), +}) + export const revokeOrganizationSessionsResponseSchema = z.object({ success: z.boolean(), data: z.object({ @@ -581,6 +626,47 @@ export const revokeOrganizationSessionsContract = defineRouteContract({ }, }) +export const listOrganizationDomainsContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/domains', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: listOrganizationDomainsResponseSchema, + }, +}) + +export const addOrganizationDomainContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/domains', + params: organizationParamsSchema, + body: addOrganizationDomainBodySchema, + response: { + mode: 'json', + schema: organizationDomainResponseSchema, + }, +}) + +export const verifyOrganizationDomainContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/domains/[domainId]/verify', + params: organizationDomainParamsSchema, + response: { + mode: 'json', + schema: organizationDomainResponseSchema, + }, +}) + +export const removeOrganizationDomainContract = defineRouteContract({ + method: 'DELETE', + path: '/api/organizations/[id]/domains/[domainId]', + params: organizationDomainParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.boolean() }), + }, +}) + // Read shape mirrors `OrganizationWhitelabelSettings` from // `@/lib/branding/types`. All fields are optional (nullable on the way in // for the PUT contract, but stored without nulls on the way out — the diff --git a/apps/sim/lib/api/contracts/permission-groups.test.ts b/apps/sim/lib/api/contracts/permission-groups.test.ts index 0b3bf3fb10f..e3460557529 100644 --- a/apps/sim/lib/api/contracts/permission-groups.test.ts +++ b/apps/sim/lib/api/contracts/permission-groups.test.ts @@ -4,8 +4,13 @@ import { describe, expect, it } from 'vitest' import { createPermissionGroupBodySchema, + permissionGroupFullConfigSchema, updatePermissionGroupBodySchema, } from '@/lib/api/contracts/permission-groups' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/types' describe('createPermissionGroupBodySchema', () => { it('accepts a name-only body (scope is resolved and validated server-side)', () => { @@ -80,3 +85,55 @@ describe('updatePermissionGroupBodySchema', () => { expect(result.success).toBe(false) }) }) + +/** + * The access-control group detail view decides whether its config buffer is + * dirty by comparing `JSON.stringify(savedConfig)` against + * `JSON.stringify(editingConfig)`, and reconciles the saved baseline from the + * update response. That only works while every config that reaches the client — + * from the list route and from the update route alike — carries the same key + * order, which holds because both pass through `parsePermissionGroupConfig` and + * then `permissionGroupFullConfigSchema`. If the two ever drift, the detail view + * would report unsaved changes forever after a successful save. + */ +describe('permissionGroupFullConfigSchema key order', () => { + it('matches parsePermissionGroupConfig so a saved config compares equal', () => { + const stored = parsePermissionGroupConfig({ + allowedIntegrations: ['slack'], + hideCopilot: true, + }) + const overWire = permissionGroupFullConfigSchema.parse(structuredClone(stored)) + expect(JSON.stringify(overWire)).toBe(JSON.stringify(stored)) + }) + + it('keeps an edited client buffer comparable to the server echo', () => { + const fromList = permissionGroupFullConfigSchema.parse( + structuredClone(parsePermissionGroupConfig(DEFAULT_PERMISSION_GROUP_CONFIG)) + ) + const edited = { ...fromList, hideDeployChatbot: true, deniedTools: ['slack_canvas'] } + const serverEcho = permissionGroupFullConfigSchema.parse( + structuredClone(parsePermissionGroupConfig(edited)) + ) + expect(JSON.stringify(serverEcho)).toBe(JSON.stringify(edited)) + }) +}) + +describe('permission group description trimming', () => { + it('trims a padded description on create', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Engineering', + description: ' padded ', + }) + expect(result.success && result.data.description).toBe('padded') + }) + + it('trims a padded description on update', () => { + const result = updatePermissionGroupBodySchema.safeParse({ description: ' padded ' }) + expect(result.success && result.data.description).toBe('padded') + }) + + it('still accepts a null description on update', () => { + const result = updatePermissionGroupBodySchema.safeParse({ description: null }) + expect(result.success && result.data.description).toBeNull() + }) +}) diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 273c47f874d..e3a531d8b43 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -148,7 +148,7 @@ function refineWorkspaceScope( export const createPermissionGroupBodySchema = z .object({ name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), + description: z.string().trim().max(500).optional(), config: permissionGroupConfigSchema.optional(), isDefault: z.boolean().optional(), workspaceIds: workspaceIdsSchema.optional(), @@ -158,7 +158,7 @@ export const createPermissionGroupBodySchema = z export const updatePermissionGroupBodySchema = z .object({ name: z.string().trim().min(1).max(100).optional(), - description: z.string().max(500).nullable().optional(), + description: z.string().trim().max(500).nullable().optional(), config: permissionGroupConfigSchema.optional(), isDefault: z.boolean().optional(), workspaceIds: workspaceIdsSchema.optional(), diff --git a/apps/sim/lib/api/contracts/providers.ts b/apps/sim/lib/api/contracts/providers.ts index 0e941a23ca7..7c40ed81154 100644 --- a/apps/sim/lib/api/contracts/providers.ts +++ b/apps/sim/lib/api/contracts/providers.ts @@ -316,6 +316,9 @@ const executeProviderResponseSchema = z input: z.number().optional(), output: z.number().optional(), total: z.number().optional(), + /** Prompt-cache buckets, reported separately from base input tokens. */ + cacheRead: z.number().optional(), + cacheWrite: z.number().optional(), }) .optional(), toolCalls: z.array(z.record(z.string(), z.unknown())).optional(), diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 950acf782e1..31af51cd489 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -8,6 +8,11 @@ export const skillSchema = z.object({ name: z.string(), description: z.string(), content: z.string(), + /** + * Whether the caller can edit, delete, and share the skill (explicit editor + * or derived workspace admin). Always false for built-in template skills. + */ + canEdit: z.boolean(), createdAt: z.string(), updatedAt: z.string(), /** True for built-in template skills, which are read-only and not stored in the DB. */ @@ -16,17 +21,57 @@ export const skillSchema = z.object({ export type Skill = z.output -export const skillUpsertItemSchema = z.object({ - id: z.string().optional(), - name: z - .string() - .min(1, 'Skill name is required') - .max(64) - .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)'), - description: z.string().min(1, 'Description is required').max(1024), - content: z.string().min(1, 'Content is required').max(50_000, 'Content is too large'), +/** + * One entry of a skill's editor roster: a workspace admin (derived, always an + * editor) or an explicitly added editor. + */ +export const skillEditorSchema = z.object({ + id: z.string(), + userId: z.string(), + userName: z.string().nullable(), + userEmail: z.string().nullable(), + userImage: z.string().nullable().optional(), + isWorkspaceAdmin: z.boolean(), }) +export type SkillEditor = z.output + +const skillNameSchema = z + .string() + .min(1, 'Skill name is required') + .max(64) + .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)') +const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) +const skillContentSchema = z + .string() + .min(1, 'Content is required') + .max(50_000, 'Content is too large') + +/** + * One skill in an upsert. Creates (no `id`) require name/description/content; + * updates (`id` set) are partial — omitted fields keep their current values + * server-side, so a partial edit can never clobber a concurrent content edit. + */ +export const skillUpsertItemSchema = z + .object({ + id: z.string().min(1).optional(), + name: skillNameSchema.optional(), + description: skillDescriptionSchema.optional(), + content: skillContentSchema.optional(), + }) + .superRefine((item, ctx) => { + if (item.id) return + if (item.name === undefined) { + ctx.addIssue({ code: 'custom', path: ['name'], message: 'Skill name is required' }) + } + if (item.description === undefined) { + ctx.addIssue({ code: 'custom', path: ['description'], message: 'Description is required' }) + } + if (item.content === undefined) { + ctx.addIssue({ code: 'custom', path: ['content'], message: 'Content is required' }) + } + }) + export const listSkillsQuerySchema = z.object({ workspaceId: z.string().min(1), }) @@ -43,10 +88,6 @@ export const deleteSkillQuerySchema = z.object({ source: z.enum(['settings', 'tool_input']).optional(), }) -export const importSkillBodySchema = z.object({ - url: z.string().url('A valid URL is required'), -}) - export const listSkillsContract = defineRouteContract({ method: 'GET', path: '/api/skills', @@ -84,14 +125,54 @@ export const deleteSkillContract = defineRouteContract({ }, }) -export const importSkillContract = defineRouteContract({ +export const skillIdParamsSchema = z.object({ + id: z.string().min(1, 'Skill id is required'), +}) + +export const upsertSkillMemberBodySchema = z.object({ + userId: z.string().min(1, 'User id is required'), +}) + +export type UpsertSkillMemberBody = z.input + +export const removeSkillMemberQuerySchema = z.object({ + userId: z.string().min(1, 'User id is required'), +}) + +export const listSkillMembersContract = defineRouteContract({ + method: 'GET', + path: '/api/skills/[id]/members', + params: skillIdParamsSchema, + response: { + mode: 'json', + schema: z.object({ + editors: z.array(skillEditorSchema), + }), + }, +}) + +export const upsertSkillMemberContract = defineRouteContract({ method: 'POST', - path: '/api/skills/import', - body: importSkillBodySchema, + path: '/api/skills/[id]/members', + params: skillIdParamsSchema, + body: upsertSkillMemberBodySchema, response: { mode: 'json', schema: z.object({ - content: z.string(), + success: z.literal(true), + }), + }, +}) + +export const removeSkillMemberContract = defineRouteContract({ + method: 'DELETE', + path: '/api/skills/[id]/members', + params: skillIdParamsSchema, + query: removeSkillMemberQuerySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), }), }, }) diff --git a/apps/sim/lib/api/contracts/tools/whatsapp.ts b/apps/sim/lib/api/contracts/tools/whatsapp.ts new file mode 100644 index 00000000000..18465dd3c3d --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/whatsapp.ts @@ -0,0 +1,136 @@ +import { z } from 'zod' +import { + nonEmptyIdSchema, + userFileSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' + +const MAX_ACCESS_TOKEN_LENGTH = 8192 +const MAX_GRAPH_ID_LENGTH = 256 + +const whatsappAccessTokenSchema = z + .string() + .min(1, 'Access token is required') + .max(MAX_ACCESS_TOKEN_LENGTH, 'Access token is too long') + +const whatsappPhoneNumberIdSchema = z + .string() + .trim() + .min(1, 'Phone Number ID is required') + .max(MAX_GRAPH_ID_LENGTH, 'Phone Number ID is too long') + +const whatsappMediaIdSchema = z + .string() + .trim() + .min(1, 'Media ID is required') + .max(MAX_GRAPH_ID_LENGTH, 'Media ID is too long') + +const executionContextShape = { + workspaceId: workspaceIdSchema.optional(), + workflowId: workflowIdSchema.optional(), + executionId: nonEmptyIdSchema.optional(), +} + +export const whatsappUploadMediaBodySchema = z.object({ + accessToken: whatsappAccessTokenSchema, + phoneNumberId: whatsappPhoneNumberIdSchema, + file: RawFileInputSchema, +}) + +export const whatsappUploadMediaOutputSchema = z.object({ + mediaId: z.string().min(1).max(MAX_GRAPH_ID_LENGTH), + fileName: z.string().min(1), + mimeType: z.string().min(1), + size: z.number().int().nonnegative(), +}) + +export const whatsappSendMediaBodySchema = z.object({ + accessToken: whatsappAccessTokenSchema, + phoneNumberId: whatsappPhoneNumberIdSchema, + phoneNumber: z.string().trim().min(1, 'Recipient phone number is required').max(64), + mediaType: z.enum(['image', 'document', 'video', 'audio', 'sticker']), + /** Exactly one of file, mediaId, or mediaLink must be supplied. */ + file: RawFileInputSchema.optional().nullable(), + mediaId: whatsappMediaIdSchema.optional().nullable(), + mediaLink: z.string().trim().max(8192).optional().nullable(), + caption: z.string().max(1024, 'Caption cannot exceed 1024 characters').optional().nullable(), + filename: z.string().max(1024).optional().nullable(), +}) + +export const whatsappSendMediaOutputSchema = z.object({ + success: z.literal(true), + messageId: z.string().min(1), + messageStatus: z.string().optional(), + messagingProduct: z.string().optional(), + inputPhoneNumber: z.string().nullable(), + whatsappUserId: z.string().nullable(), + contacts: z.array(z.object({ input: z.string(), wa_id: z.string().nullable() })), + /** Set only when a file was uploaded as part of this send. */ + mediaId: z.string().optional(), +}) + +export const whatsappGetMediaBodySchema = z.object({ + accessToken: whatsappAccessTokenSchema, + mediaId: whatsappMediaIdSchema, + /** Optional ownership scoping check accepted by `GET /{media-id}`. */ + phoneNumberId: whatsappPhoneNumberIdSchema.optional(), + ...executionContextShape, +}) + +export const whatsappGetMediaOutputSchema = z.object({ + file: userFileSchema, + mediaId: z.string().min(1).max(MAX_GRAPH_ID_LENGTH), + mimeType: z.string().min(1), + fileSize: z.number().int().nonnegative(), + sha256: z.string().nullable(), +}) + +const whatsappRouteResponseSchema = (output: TOutput) => + z.discriminatedUnion('success', [ + z.object({ success: z.literal(true), output }), + z.object({ success: z.literal(false), error: z.string().min(1) }), + ]) + +export const whatsappUploadMediaResponseSchema = whatsappRouteResponseSchema( + whatsappUploadMediaOutputSchema +) +export const whatsappSendMediaResponseSchema = whatsappRouteResponseSchema( + whatsappSendMediaOutputSchema +) +export const whatsappGetMediaResponseSchema = whatsappRouteResponseSchema( + whatsappGetMediaOutputSchema +) + +export const whatsappUploadMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/whatsapp/upload-media', + body: whatsappUploadMediaBodySchema, + response: { mode: 'json', schema: whatsappUploadMediaResponseSchema }, +}) + +export const whatsappSendMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/whatsapp/send-media', + body: whatsappSendMediaBodySchema, + response: { mode: 'json', schema: whatsappSendMediaResponseSchema }, +}) + +export const whatsappGetMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/whatsapp/get-media', + body: whatsappGetMediaBodySchema, + response: { mode: 'json', schema: whatsappGetMediaResponseSchema }, +}) + +export type WhatsAppUploadMediaBody = ContractBodyInput +export type WhatsAppSendMediaBody = ContractBodyInput +export type WhatsAppGetMediaBody = ContractBodyInput +export type WhatsAppUploadMediaRouteResponse = ContractJsonResponse< + typeof whatsappUploadMediaContract +> +export type WhatsAppSendMediaRouteResponse = ContractJsonResponse +export type WhatsAppGetMediaRouteResponse = ContractJsonResponse diff --git a/apps/sim/lib/auth/sso/domain-verification.test.ts b/apps/sim/lib/auth/sso/domain-verification.test.ts new file mode 100644 index 00000000000..e44dfcc8f3f --- /dev/null +++ b/apps/sim/lib/auth/sso/domain-verification.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveTxt, mockSetServers } = vi.hoisted(() => ({ + mockResolveTxt: vi.fn(), + mockSetServers: vi.fn(), +})) + +vi.mock('node:dns/promises', () => ({ + Resolver: class { + resolveTxt = mockResolveTxt + setServers = mockSetServers + }, +})) + +import { + buildChallengeHost, + buildTxtRecordValue, + checkDomainTxtRecord, + generateVerificationToken, + SSO_CHALLENGE_HOST_PREFIX, + toDomainResponse, +} from '@/lib/auth/sso/domain-verification' + +describe('domain-verification helpers', () => { + it('builds the challenge host on the underscore-prefixed label', () => { + expect(buildChallengeHost('acme.com')).toBe(`${SSO_CHALLENGE_HOST_PREFIX}.acme.com`) + expect(buildChallengeHost('eng.acme.com')).toBe('_sim-challenge.eng.acme.com') + }) + + it('prefixes the TXT value so it is unambiguous among other records', () => { + expect(buildTxtRecordValue('abc123')).toBe('sim-domain-verification=abc123') + }) + + it('generates high-entropy, unique tokens', () => { + const a = generateVerificationToken() + const b = generateVerificationToken() + expect(a).not.toBe(b) + expect(a.length).toBeGreaterThanOrEqual(32) + }) + + describe('toDomainResponse', () => { + it('exposes the TXT value only for pending domains', () => { + const pending = toDomainResponse({ + id: 'd1', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, + }) + expect(pending).toEqual({ + id: 'd1', + domain: 'acme.com', + status: 'pending', + verifiedAt: null, + challengeHost: '_sim-challenge.acme.com', + txtRecordValue: 'sim-domain-verification=tok', + }) + }) + + it('redacts the pending TXT value when includeToken is false', () => { + const redacted = toDomainResponse( + { + id: 'd1', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, + }, + { includeToken: false } + ) + expect(redacted.status).toBe('pending') + expect(redacted.txtRecordValue).toBeNull() + expect(redacted.challengeHost).toBe('_sim-challenge.acme.com') + }) + + it('never leaks the token for a verified domain', () => { + const verifiedAt = new Date('2026-07-23T00:00:00.000Z') + const verified = toDomainResponse({ + id: 'd2', + domain: 'acme.com', + status: 'verified', + verificationToken: 'secret', + verifiedAt, + }) + expect(verified.status).toBe('verified') + expect(verified.txtRecordValue).toBeNull() + expect(verified.verifiedAt).toBe(verifiedAt.toISOString()) + }) + }) + + describe('checkDomainTxtRecord', () => { + const TOKEN = 'tok-123' + const EXPECTED = buildTxtRecordValue(TOKEN) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('queries the challenge host for the domain', async () => { + mockResolveTxt.mockResolvedValue([[EXPECTED]]) + await checkDomainTxtRecord('acme.com', TOKEN) + expect(mockResolveTxt).toHaveBeenCalledWith('_sim-challenge.acme.com') + }) + + it('verifies when the exact value is published', async () => { + mockResolveTxt.mockResolvedValue([[EXPECTED]]) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true) + }) + + it('joins a value split across 255-char chunks before comparing', async () => { + const midpoint = Math.floor(EXPECTED.length / 2) + mockResolveTxt.mockResolvedValue([[EXPECTED.slice(0, midpoint), EXPECTED.slice(midpoint)]]) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true) + }) + + it('finds the match among unrelated TXT records on the same host', async () => { + mockResolveTxt.mockResolvedValue([ + ['v=spf1 include:_spf.google.com ~all'], + ['facebook-domain-verification=abc123'], + [EXPECTED], + ]) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true) + }) + + it('tolerates padding a DNS panel added around the value', async () => { + mockResolveTxt.mockResolvedValue([[` ${EXPECTED} `]]) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true) + }) + + it('rejects a near-miss value (no partial or prefix match)', async () => { + mockResolveTxt.mockResolvedValue([[`${EXPECTED}extra`], [EXPECTED.slice(0, -1)]]) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false) + }) + + it('rejects another org token published on the same host', async () => { + mockResolveTxt.mockResolvedValue([[buildTxtRecordValue('someone-elses-token')]]) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false) + }) + + it('returns false (never throws) when the record is absent', async () => { + mockResolveTxt.mockRejectedValue(Object.assign(new Error('no data'), { code: 'ENODATA' })) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false) + }) + + it('returns false (never throws) when resolution fails for an infrastructure reason', async () => { + mockResolveTxt.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' })) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false) + }) + + it('returns false when the host has no TXT records at all', async () => { + mockResolveTxt.mockResolvedValue([]) + await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false) + }) + }) +}) diff --git a/apps/sim/lib/auth/sso/domain-verification.ts b/apps/sim/lib/auth/sso/domain-verification.ts new file mode 100644 index 00000000000..79e8d1ef575 --- /dev/null +++ b/apps/sim/lib/auth/sso/domain-verification.ts @@ -0,0 +1,138 @@ +import { Resolver } from 'node:dns/promises' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import type { OrganizationDomain } from '@/lib/api/contracts/organization' + +const logger = createLogger('SSODomainVerification') + +interface SsoDomainRow { + id: string + domain: string + status: string + verificationToken: string + verifiedAt: Date | null +} + +/** + * Maps a stored `sso_domain` row to its API shape. The TXT value (which embeds + * the verification token) is only returned for `pending` domains, and only when + * `includeToken` is set — an already-verified row has no reason to expose its + * token, and the token is a management secret that non-admin readers must not + * see. Callers that gate on owner/admin (add/verify) leave it defaulted; a + * member-readable listing passes `includeToken: false` for non-admins. + */ +export function toDomainResponse( + row: SsoDomainRow, + options: { includeToken?: boolean } = {} +): OrganizationDomain { + const { includeToken = true } = options + const status = row.status === 'verified' ? 'verified' : 'pending' + return { + id: row.id, + domain: row.domain, + status, + verifiedAt: row.verifiedAt ? row.verifiedAt.toISOString() : null, + challengeHost: buildChallengeHost(row.domain), + txtRecordValue: + status === 'pending' && includeToken ? buildTxtRecordValue(row.verificationToken) : null, + } +} + +/** + * DNS label the verification TXT record lives under, prefixed to the domain + * being verified (e.g. `_sim-challenge.acme.com`). A dedicated underscore host + * — rather than the apex — avoids colliding with the domain's SPF/DMARC/other + * root TXT records and is the industry-standard placement. + */ +export const SSO_CHALLENGE_HOST_PREFIX = '_sim-challenge' + +/** Prefix on the TXT record value, so the token is unambiguous among other TXT records. */ +const TXT_VALUE_PREFIX = 'sim-domain-verification=' + +/** Public nameservers used for the challenge lookup, so verification does not + * depend on (or get poisoned by) the host's local resolver/split-horizon DNS. */ +const VERIFICATION_NAMESERVERS = ['1.1.1.1', '8.8.8.8'] + +/** + * Per-attempt timeout. c-ares multiplies this across servers and retries by + * more than the nominal `tries` (measured ~7x with two servers), so keep the + * base low: 2s x 1 try over two servers bounds a fully-unreachable-resolver + * lookup at a few seconds rather than the ~35s a 5s/2-try config produced. + */ +const DNS_TIMEOUT_MS = 2000 + +/** + * DNS error codes that genuinely mean "the record is not published yet" — the + * expected state while an admin is still adding it. Anything else (timeout, + * refused, SERVFAIL) indicates an infrastructure problem on our side and is + * logged loudly, because it is otherwise indistinguishable to the admin from a + * missing record. + */ +const RECORD_ABSENT_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'NXDOMAIN']) + +/** + * Shared resolver pinned to the public nameservers. Its config is fully static + * and `resolveTxt` is safe to call concurrently, so a single module-scope + * instance avoids re-allocating one per verification. + */ +const verificationResolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 }) +verificationResolver.setServers(VERIFICATION_NAMESERVERS) + +/** The fully-qualified host an org must create the TXT record on. */ +export function buildChallengeHost(domain: string): string { + return `${SSO_CHALLENGE_HOST_PREFIX}.${domain}` +} + +/** The exact TXT record value an org must publish for a given token. */ +export function buildTxtRecordValue(token: string): string { + return `${TXT_VALUE_PREFIX}${token}` +} + +/** + * Generates a high-entropy verification token (~190 bits, URL-safe). Unguessable + * so an attacker cannot pre-create the TXT record for a domain they don't own. + */ +export function generateVerificationToken(): string { + return generateShortId(32) +} + +/** + * Resolves the challenge host's TXT records against public nameservers and + * returns true when the expected `sim-domain-verification=` value is + * present. Never throws — resolution failures (NXDOMAIN, timeout, missing + * record) resolve to `false` so a not-yet-propagated record simply reads as + * unverified. + */ +export async function checkDomainTxtRecord(domain: string, token: string): Promise { + const host = buildChallengeHost(domain) + const expected = buildTxtRecordValue(token) + + try { + const records = await verificationResolver.resolveTxt(host) + // Each TXT record may be split into 255-char chunks — join before comparing. + // Trim the joined value: several DNS panels pad the stored string, which + // would otherwise fail an exact match forever with no way for the admin to + // tell why. Concatenation happens first, so trimming cannot corrupt a + // legitimate chunk boundary. + return records.some((chunks) => chunks.join('').trim() === expected) + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code + if (code && RECORD_ABSENT_DNS_CODES.has(code)) { + logger.debug('TXT verification record not published yet', { host, code }) + } else { + // Not a missing record — our resolver path itself is failing (blocked + // egress, timeout, SERVFAIL). Log at ERROR, not warn: the default minimum + // level in production is ERROR, so anything below it is dropped and the + // fault stays invisible while the admin is told their record "isn't + // published yet". This is a genuine infrastructure fault, so ERROR is also + // the honest severity. + logger.error('TXT verification lookup failed for an infrastructure reason', { + host, + code, + error: getErrorMessage(error), + }) + } + return false + } +} diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 7d2955ef587..3cb64bedefc 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -47,6 +47,7 @@ import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import type { DbOrTx } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, WorkspaceBillingAccountRemovalError, @@ -1470,6 +1471,7 @@ export async function transferUserBetweenOrganizations( workspaceIds, params.userId ) + await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, params.userId) } const [stats] = await tx @@ -1721,6 +1723,7 @@ export async function removeUserFromOrganization( workspaceIds, userId ) + await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, userId) const capturedUsage = await captureDepartedUsage() return { @@ -1935,6 +1938,7 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: { workspaceIds, userId ) + await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, userId) return { workspaceAccessRevoked: deletedPermissions.length, diff --git a/apps/sim/lib/content/mdx.tsx b/apps/sim/lib/content/mdx.tsx index 8d6d840b12c..0e82c6124c2 100644 --- a/apps/sim/lib/content/mdx.tsx +++ b/apps/sim/lib/content/mdx.tsx @@ -1,8 +1,32 @@ import clsx from 'clsx' import type { MDXRemoteProps } from 'next-mdx-remote/rsc' import { CodeBlock } from '@/lib/content/code' +import { SITE_URL } from '@/lib/core/utils/urls' import { ContentImage } from '@/app/(landing)/components/content-image' +/** + * Apex host for every Sim-owned property, derived from the canonical site URL + * rather than the environment so a post renders identically in dev, preview, + * and production. + */ +const SITE_APEX_HOST = new URL(SITE_URL).hostname.replace(/^www\./, '') + +/** + * True only for links that leave Sim entirely. Relative hrefs, in-page anchors, + * the apex host, and any Sim subdomain (`www.`, `docs.`) are first-party and keep + * default same-tab navigation so internal linking stays crawlable. The leading dot + * in the suffix check keeps lookalike domains such as `evil-sim.ai` external. + */ +function isExternalHref(href: string | undefined): boolean { + if (!href || !/^https?:\/\//i.test(href)) return false + try { + const { hostname } = new URL(href) + return hostname !== SITE_APEX_HOST && !hostname.endsWith(`.${SITE_APEX_HOST}`) + } catch { + return false + } +} + export const mdxComponents: MDXRemoteProps['components'] = { img: (props: any) => ( } + /** + * Outbound citations in post bodies open in a new tab and carry + * `rel="noopener noreferrer"`, per `.claude/rules/landing-seo-geo.md`. + */ + const isExternal = isExternalHref(props.href) return ( { try { - await assertActiveWorkspaceAccess(workspaceId, userId) - const wsRow = await getWorkspaceWithOwner(workspaceId) + // Reuse the caller's already-asserted access when provided (hot chat path); + // the id match keeps a mismatched cache from authorizing this workspace. + const workspaceAccess = + options?.workspaceAccess && options.workspaceAccess.workspace?.id === workspaceId + ? options.workspaceAccess + : await assertActiveWorkspaceAccess(workspaceId, userId) + const wsRow = workspaceAccess.hasAccess ? workspaceAccess.workspace : null if (!wsRow) { return null } @@ -421,7 +427,7 @@ async function buildWorkspaceMdData( .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))), - listSkills({ workspaceId, includeBuiltins: false }), + listSkillsForUser({ workspaceId, userId, includeBuiltins: false, workspaceAccess }), db .select({ @@ -555,9 +561,10 @@ const WORKSPACE_CONTEXT_UNAVAILABLE_MD = */ export async function generateWorkspaceContext( workspaceId: string, - userId: string + userId: string, + options?: { workspaceAccess?: WorkspaceAccess } ): Promise { - const data = await buildWorkspaceMdData(workspaceId, userId) + const data = await buildWorkspaceMdData(workspaceId, userId, options) return data ? buildWorkspaceMd(data) : WORKSPACE_CONTEXT_UNAVAILABLE_MD } diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 406ef9f0953..9316dff9070 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -182,7 +182,6 @@ interface OrchestratorRequest { fileAttachments?: FileAttachment[] commands?: string[] provider?: string - streamToolCalls?: boolean version?: string prefetch?: boolean userName?: string diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 20282e78f4d..14d4ef9694c 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -308,6 +308,9 @@ export async function executeDeployChat( allowedEmails: (existing[0].allowedEmails as string[]) || [], outputConfigs: (existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [], + includeThinking: existing[0].includeThinking ?? false, + includeToolCalls: + existing[0].includeToolCalls ?? existing[0].includeThinking ?? false, welcomeMessage: (existing[0].customizations as { welcomeMessage?: string } | null) ?.welcomeMessage || 'Hi there! How can I help you today?', @@ -395,6 +398,19 @@ export async function executeDeployChat( blockId: string path: string }> + const resolvedIncludeThinking = + typeof params.includeThinking === 'boolean' + ? params.includeThinking + : (existingDeployment?.includeThinking ?? false) + /** + * Grandfathers off the thinking value this call resolves to, not the stored + * one: before `includeToolCalls` existed, `includeThinking` gated tool + * frames too, so turning thinking off must turn them off with it. + */ + const resolvedIncludeToolCalls = + typeof params.includeToolCalls === 'boolean' + ? params.includeToolCalls + : (existingDeployment?.includeToolCalls ?? resolvedIncludeThinking) const welcomeMessage = typeof params.welcomeMessage === 'string' ? params.welcomeMessage @@ -438,6 +454,8 @@ export async function executeDeployChat( password: params.password, allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, + includeThinking: resolvedIncludeThinking, + includeToolCalls: resolvedIncludeToolCalls, workspaceId: workflowRecord.workspaceId, }) @@ -490,6 +508,8 @@ export async function executeDeployChat( authType: resolvedAuthType, allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, + includeThinking: resolvedIncludeThinking, + includeToolCalls: resolvedIncludeToolCalls, welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', primaryColor: params.customizations?.primaryColor || diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index e1b35503c59..6ca6bdb4a26 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -61,6 +61,8 @@ export async function executeCheckDeploymentStatus( authType: chat.authType, allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, password: chat.password, customizations: chat.customizations, }) @@ -103,6 +105,8 @@ export async function executeCheckDeploymentStatus( authType: chatDeploy[0]?.authType || null, allowedEmails: chatDeploy[0]?.allowedEmails || null, outputConfigs: chatDeploy[0]?.outputConfigs || null, + includeThinking: chatDeploy[0]?.includeThinking ?? false, + includeToolCalls: chatDeploy[0]?.includeToolCalls ?? chatDeploy[0]?.includeThinking ?? false, welcomeMessage: chatCustomizations.welcomeMessage || null, primaryColor: chatCustomizations.primaryColor || null, hasPassword: Boolean(chatDeploy[0]?.password), diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index dc551e382e3..e3c1c7bfebc 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -3,7 +3,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { captureServerEvent } from '@/lib/posthog/server' -import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' +import { getSkillActorContext } from '@/lib/skills/access' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' const logger = createLogger('CopilotToolExecutor') @@ -33,22 +35,23 @@ export async function executeManageSkill( return { success: false, error: 'workspaceId is required' } } - const writeOps: string[] = ['add', 'edit', 'delete'] + // Workspace write gates only creation; edits and deletes are gated per skill + // below (skill editor — explicit editor row or derived workspace admin). if ( - writeOps.includes(operation) && + operation === 'add' && context.userPermission && context.userPermission !== 'write' && context.userPermission !== 'admin' ) { return { success: false, - error: `Permission denied: '${operation}' on manage_skill requires write access. You have '${context.userPermission}' permission.`, + error: `Permission denied: 'add' on manage_skill requires write access. You have '${context.userPermission}' permission.`, } } try { if (operation === 'list') { - const skills = await listSkills({ workspaceId }) + const skills = await listSkillsForUser({ workspaceId, userId: context.userId }) return { success: true, @@ -128,26 +131,36 @@ export async function executeManageSkill( } } - const existing = await listSkills({ workspaceId }) - const found = existing.find((s) => s.id === params.skillId) - if (!found) { + if (isBuiltinSkillId(params.skillId)) { + return { success: false, error: 'Built-in skills are read-only and cannot be modified' } + } + + const actor = await getSkillActorContext(params.skillId, context.userId) + if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { return { success: false, error: `Skill not found: ${params.skillId}` } } + if (!actor.canEdit) { + return { + success: false, + error: `Permission denied: editing skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, + } + } + // Partial update: omitted fields keep their current values server-side. await upsertSkills({ skills: [ { id: params.skillId, - name: params.name || found.name, - description: params.description || found.description, - content: params.content || found.content, + ...(params.name ? { name: params.name } : {}), + ...(params.description ? { description: params.description } : {}), + ...(params.content ? { content: params.content } : {}), }, ], workspaceId, userId: context.userId, }) - const updatedName = params.name || found.name + const updatedName = params.name || actor.skill.name recordAudit({ workspaceId, actorId: context.userId, @@ -176,8 +189,8 @@ export async function executeManageSkill( success: true, operation, skillId: params.skillId, - name: params.name || found.name, - message: `Updated skill "${params.name || found.name}"`, + name: updatedName, + message: `Updated skill "${updatedName}"`, }, } } @@ -187,6 +200,19 @@ export async function executeManageSkill( return { success: false, error: "'skillId' is required for 'delete'" } } + if (!isBuiltinSkillId(params.skillId)) { + const actor = await getSkillActorContext(params.skillId, context.userId) + if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { + return { success: false, error: `Skill not found: ${params.skillId}` } + } + if (!actor.canEdit) { + return { + success: false, + error: `Permission denied: deleting skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, + } + } + } + const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) if (!deleted) { return { success: false, error: `Skill not found: ${params.skillId}` } diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index 16fdbd8bf02..a111a87f854 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -158,6 +158,8 @@ export interface DeployChatParams { subdomain?: string allowedEmails?: string[] outputConfigs?: unknown[] + includeThinking?: boolean + includeToolCalls?: boolean } export interface DeployMcpParams { diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts index f65efd94542..18a53134c53 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts @@ -3,9 +3,9 @@ */ import { describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/execution/e2b', () => ({ - executeInE2B: vi.fn(), - executeShellInE2B: vi.fn(), +vi.mock('@/lib/execution/remote-sandbox', () => ({ + executeInSandbox: vi.fn(), + executeShellInSandbox: vi.fn(), })) vi.mock('@/lib/execution/languages', () => ({ CodeLanguage: { javascript: 'javascript', python: 'python' }, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index b9a6ea0a260..5f2bf0b7e22 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -1,10 +1,14 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' -import { isE2BDocEnabled } from '@/lib/core/config/env-flags' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { executeInE2B, executeShellInE2B, type SandboxFile } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' +import { + executeInSandbox, + executeShellInSandbox, + type SandboxFile, +} from '@/lib/execution/remote-sandbox' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { fetchWorkspaceFileBuffer, @@ -57,7 +61,7 @@ export interface E2BDocFormat { /** * Resolves the E2B doc format + engine for a filename, or null for non-docs. * pptx/docx → node, pdf/xlsx → python. Only meaningful when the E2B doc sandbox - * is enabled; callers gate on isE2BDocEnabled before using this. + * is enabled; callers gate on isDocSandboxEnabled before using this. */ export async function getE2BDocFormat(fileName: string): Promise { const l = fileName.toLowerCase() @@ -259,7 +263,7 @@ async function compileDocViaE2BPython( // unaffected. Runs only after the user's script succeeds. const code = fmt.ext === 'xlsx' ? `${source}\n${XLSX_RECALC_SNIPPET}` : source - const result = await executeInE2B({ + const result = await executeInSandbox({ code, language: CodeLanguage.Python, timeoutMs: DOC_COMPILE_TIMEOUT_MS, @@ -342,7 +346,7 @@ ${finalize} })().then(() => console.log('__DOC_OK__')).catch((e) => { console.error('__DOC_ERR__' + (e && e.message ? e.message : String(e))); process.exit(1); }); ` - const result = await executeShellInE2B({ + const result = await executeShellInSandbox({ code: 'NODE_PATH=$(npm root -g) node /home/user/script.js', envs: {}, timeoutMs: DOC_COMPILE_TIMEOUT_MS, @@ -532,7 +536,7 @@ export async function resolveServableDocBytes(args: { if (stored) { return { buffer: stored.buffer, contentType: stored.contentType } } - if (isE2BDocEnabled && (await getE2BDocFormat(fileName))) { + if (isDocSandboxEnabled && (await getE2BDocFormat(fileName))) { throw new DocCompileUserError('Document is still being generated') } } diff --git a/apps/sim/lib/copilot/tools/server/files/doc-extract.ts b/apps/sim/lib/copilot/tools/server/files/doc-extract.ts index 1674b7c7af7..92230ac871a 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-extract.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-extract.ts @@ -1,5 +1,5 @@ -import { executeInE2B } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' +import { executeInSandbox } from '@/lib/execution/remote-sandbox' const EXTRACT_TIMEOUT_MS = 120_000 // Bound the text handed back to the agent so a huge document can't blow the @@ -87,7 +87,7 @@ text = "\\n".join(out)[:${MAX_EXTRACT_CHARS + 20000}] print("__SIM_RESULT__=" + json.dumps({"text": text})) `.trim() - const result = await executeInE2B({ + const result = await executeInSandbox({ code: script, language: CodeLanguage.Python, timeoutMs: EXTRACT_TIMEOUT_MS, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts index dd8c43b3499..c85b9249110 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' -import { executeInE2B } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' +import { executeInSandbox } from '@/lib/execution/remote-sandbox' import { compileDoc, DocCompileUserError } from './doc-compile' const logger = createLogger('CopilotDocRecalc') @@ -50,7 +50,7 @@ for ws in wb.worksheets: print("__SIM_RESULT__=" + json.dumps({"ok": len(errors) == 0, "errors": errors[:${MAX_REPORTED_ERRORS}]})) `.trim() - const result = await executeInE2B({ + const result = await executeInSandbox({ code: script, language: CodeLanguage.Python, timeoutMs: RECALC_TIMEOUT_MS, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-render.ts b/apps/sim/lib/copilot/tools/server/files/doc-render.ts index 70b3a2a2d97..24262b48825 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-render.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-render.ts @@ -1,5 +1,5 @@ -import { executeInE2B } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' +import { executeInSandbox } from '@/lib/execution/remote-sandbox' const RENDER_TIMEOUT_MS = 150_000 // Bound the visual-QA cost: cap pages and rasterization DPI so the JPEGs the @@ -84,7 +84,7 @@ else: print("__SIM_RESULT__=" + json.dumps({"grid": base64.b64encode(f.read()).decode(), "pageCount": n})) `.trim() - const result = await executeInE2B({ + const result = await executeInSandbox({ code: script, language: CodeLanguage.Python, timeoutMs: RENDER_TIMEOUT_MS, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index 73a1b4d4892..14542d6f584 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -10,9 +10,9 @@ const { betaFlag, mockLoadCompiledDoc, mockRunSandboxTask } = vi.hoisted(() => ( mockRunSandboxTask: vi.fn(), })) -vi.mock('@/lib/execution/e2b', () => ({ - executeInE2B: vi.fn(), - executeShellInE2B: vi.fn(), +vi.mock('@/lib/execution/remote-sandbox', () => ({ + executeInSandbox: vi.fn(), + executeShellInSandbox: vi.fn(), })) vi.mock('@/lib/execution/languages', () => ({ CodeLanguage: { javascript: 'javascript', python: 'python' }, @@ -53,7 +53,7 @@ afterAll(resetEnvFlagsMock) describe('resolveServableDocBytes', () => { beforeEach(() => { vi.clearAllMocks() - setEnvFlags({ isE2BDocEnabled: true }) + setEnvFlags({ isDocSandboxEnabled: true }) betaFlag.value = false }) @@ -90,7 +90,7 @@ describe('resolveServableDocBytes', () => { it('throws DocCompileUserError when a generated doc artifact is not ready (E2B regime)', async () => { mockLoadCompiledDoc.mockResolvedValue(null) - setEnvFlags({ isE2BDocEnabled: true }) + setEnvFlags({ isDocSandboxEnabled: true }) await expect( resolveServableDocBytes({ @@ -105,7 +105,7 @@ describe('resolveServableDocBytes', () => { it('compiles via the sandbox when E2B is disabled and no artifact is stored', async () => { mockLoadCompiledDoc.mockResolvedValue(null) - setEnvFlags({ isE2BDocEnabled: false }) + setEnvFlags({ isDocSandboxEnabled: false }) const compiled = Buffer.from('%PDF-isolated-vm-binary') mockRunSandboxTask.mockResolvedValue(compiled) @@ -150,7 +150,7 @@ describe('resolveServableDocBytes', () => { it('throws when a generated XLSX artifact is not ready (E2B + mothership-beta enabled)', async () => { mockLoadCompiledDoc.mockResolvedValue(null) - setEnvFlags({ isE2BDocEnabled: true }) + setEnvFlags({ isDocSandboxEnabled: true }) betaFlag.value = true await expect( diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index ffc12f6721e..f854f670853 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -5,7 +5,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { isE2BDocEnabled } from '@/lib/core/config/env-flags' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getE2BDocFormat } from './doc-compile' import { buildEmbeddedImageRefWarning } from './embedded-image-refs' @@ -71,7 +71,7 @@ export const editContentServerTool: BaseServerTool { const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args const docInfo = getDocumentFormatInfo(fileName) - const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileName) : null + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileName) : null if (!e2bFmt && fileName.toLowerCase().endsWith('.xlsx')) { return { ok: false, - message: isE2BDocEnabled + message: isDocSandboxEnabled ? 'Excel (.xlsx) generation is currently behind the mothership-beta feature flag and is not available.' - : 'Excel (.xlsx) generation requires the E2B document sandbox, which is not enabled in this environment.', + : 'Excel (.xlsx) generation requires the document sandbox, which is not enabled in this environment.', } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 2e07211fc51..eeb6516f9f5 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -98,7 +98,7 @@ import { workspacePlansBackingFolderPath, } from '@/lib/copilot/vfs/workflow-aliases' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' -import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, @@ -935,7 +935,7 @@ export class WorkspaceVFS { totalLines: 1, } } - if (isE2BDocEnabled && (await getE2BDocFormat(record.name))) { + if (isDocSandboxEnabled && (await getE2BDocFormat(record.name))) { bin = ( await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) ).buffer @@ -988,7 +988,7 @@ export class WorkspaceVFS { record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(record.name) : null const taskId = BINARY_DOC_TASKS[ext] if (!e2bFmt && !taskId) return null @@ -1114,7 +1114,7 @@ export class WorkspaceVFS { } const extractMatch = /^files\/.+\/extract$/.test(path) - if (extractMatch && isE2BDocEnabled) { + if (extractMatch && isDocSandboxEnabled) { let record: WorkspaceFileRecord | null = null try { record = await this.resolveWorkspaceFileForDynamicRead(path, 'extract') @@ -1183,7 +1183,7 @@ export class WorkspaceVFS { record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled-check') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(record.name) : null const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) return null @@ -2154,9 +2154,11 @@ export class WorkspaceVFS { } /** - * Advertise workspace skills in the VFS without eagerly loading their bodies. - * Paths are registered as lazy so glob/WORKSPACE.md see them, but full content - * is fetched only when read (or a grep whose scope touches the path) resolves them. + * Advertise the workspace skills in the VFS without eagerly loading their + * bodies. Paths are registered as lazy so glob/WORKSPACE.md see them, but + * full content is fetched only when read (or a grep whose scope touches the + * path) resolves them. Skills are workspace-visible — everyone with + * workspace access sees and uses every skill. */ private async materializeSkills( workspaceId: string diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index eaeeaabcac8..4df5b046c36 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -219,23 +219,41 @@ export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED) export const isForkingEnabled = isTruthy(env.FORKING_ENABLED) /** - * Is E2B enabled for remote code execution + * The selected remote sandbox provider (`SANDBOX_PROVIDER`), defaulting to E2B. + * Availability below is derived from THIS provider's credentials, so a + * Daytona-only deployment (E2B unset) still enables remote execution. */ -export const isE2bEnabled = isTruthy(env.E2B_ENABLED) +const sandboxProvider = (env.SANDBOX_PROVIDER || 'e2b').toLowerCase() /** - * Whether the E2B document-generation sandbox is enabled. + * Whether remote code/shell execution is available with the selected provider. * - * Requires E2B (with an API key) AND a dedicated doc-generation template id. - * When true, ALL four formats compile in the E2B doc sandbox: pptx/docx via Node + * E2B keeps its explicit `E2B_ENABLED` switch; Daytona is available once its API + * key is set (the shell snapshot is verified at create time, failing closed). + * Mirrors the E2B gate exactly when the provider is E2B, so existing behavior is + * unchanged. + */ +export const isRemoteSandboxEnabled = + sandboxProvider === 'daytona' ? Boolean(env.DAYTONA_API_KEY) : isTruthy(env.E2B_ENABLED) + +/** + * Whether the document-generation sandbox is available with the selected + * provider — its credential AND its dedicated doc image (E2B doc template, or + * Daytona doc snapshot). + * + * When true, ALL four formats compile in the doc sandbox: pptx/docx via Node * (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python * (reportlab/openpyxl). When false, compilation stays on the JavaScript * (isolated-vm) path, byte-identical to its prior behavior (and xlsx is * unavailable). Drives both the Sim compile backend and the `docCompiler` flag * sent to the copilot file subagent so the agent's output and compiler agree. */ -export const isE2BDocEnabled = - isE2bEnabled && Boolean(env.E2B_API_KEY) && Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) +export const isDocSandboxEnabled = + sandboxProvider === 'daytona' + ? Boolean(env.DAYTONA_API_KEY) && Boolean(env.DAYTONA_DOC_SNAPSHOT_ID) + : isTruthy(env.E2B_ENABLED) && + Boolean(env.E2B_API_KEY) && + Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) /** * Whether Ollama is configured (OLLAMA_URL is set). diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 683f4356ac8..80c9081dcc2 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -454,6 +454,15 @@ export const env = createEnv({ MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Create PR and Review Code) + // Remote Code Execution provider selection + SANDBOX_PROVIDER: z.string().optional(), // Which sandbox provider serves remote executions: 'e2b' (default) or 'daytona' + + // Daytona Remote Code Execution (used when SANDBOX_PROVIDER=daytona) + DAYTONA_API_KEY: z.string().optional(), // Daytona API key; needs write:snapshots to build images, write:sandboxes to run them + DAYTONA_SHELL_SNAPSHOT_ID: z.string().optional(), // Daytona snapshot mirroring mothership-shell (must carry an explicit tag; latest is rejected) + DAYTONA_DOC_SNAPSHOT_ID: z.string().optional(), // Daytona snapshot mirroring mothership-docs + DAYTONA_PI_SNAPSHOT_ID: z.string().optional(), // Daytona snapshot mirroring the Pi template (Create PR and Review Code) + // Access Control (Permission Groups) - for self-hosted deployments ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/execution-limits/types.test.ts b/apps/sim/lib/core/execution-limits/types.test.ts index a6a78777419..4556d8990a2 100644 --- a/apps/sim/lib/core/execution-limits/types.test.ts +++ b/apps/sim/lib/core/execution-limits/types.test.ts @@ -35,6 +35,7 @@ declare module '@/lib/core/execution-limits/types?execution-limits-test' { import { createTimeoutAbortController, getExecutionTimeout, + isTimeoutAbortReason, } from '@/lib/core/execution-limits/types?execution-limits-test' afterAll(resetEnvFlagsMock) @@ -80,4 +81,35 @@ describe('getExecutionTimeout', () => { vi.useRealTimers() } }) + + it('aborts with an AbortError carrying the timeout reason when the timer fires', () => { + vi.useFakeTimers() + try { + const controller = createTimeoutAbortController(1000) + vi.advanceTimersByTime(1000) + expect(controller.signal.aborted).toBe(true) + expect(controller.isTimedOut()).toBe(true) + const reason = controller.signal.reason as DOMException + expect(reason).toBeInstanceOf(DOMException) + expect(reason.name).toBe('AbortError') + expect(reason.message).toBe('timeout') + expect(isTimeoutAbortReason(reason)).toBe(true) + controller.cleanup() + } finally { + vi.useRealTimers() + } + }) + + it('manual abort uses an AbortError carrying the user reason', () => { + const controller = createTimeoutAbortController(60_000) + controller.abort() + expect(controller.signal.aborted).toBe(true) + expect(controller.isTimedOut()).toBe(false) + const reason = controller.signal.reason as DOMException + expect(reason).toBeInstanceOf(DOMException) + expect(reason.name).toBe('AbortError') + expect(reason.message).toBe('user') + expect(isTimeoutAbortReason(reason)).toBe(false) + controller.cleanup() + }) }) diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts index d11ddc22892..c747ff7ff83 100644 --- a/apps/sim/lib/core/execution-limits/types.ts +++ b/apps/sim/lib/core/execution-limits/types.ts @@ -144,6 +144,19 @@ export interface TimeoutAbortController { timeoutMs: number | undefined } +/** + * True when an abort signal's reason marks an execution timeout. Abort reasons + * are `DOMException('timeout' | 'user', 'AbortError')` so code that passes the + * signal straight into `fetch` still sees a standard AbortError, while pumps + * and executors can discriminate timeout from user Stop via the message. + */ +export function isTimeoutAbortReason(reason: unknown): boolean { + if (reason === 'timeout') return true + return ( + reason instanceof DOMException && reason.name === 'AbortError' && reason.message === 'timeout' + ) +} + export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortController { const abortController = new AbortController() let isTimedOut = false @@ -152,7 +165,8 @@ export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortCo if (timeoutMs) { timeoutId = setTimeout(() => { isTimedOut = true - abortController.abort() + // AbortError with a typed message — see isTimeoutAbortReason. + abortController.abort(new DOMException('timeout', 'AbortError')) }, timeoutMs) } @@ -162,7 +176,8 @@ export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortCo cleanup: () => { if (timeoutId) clearTimeout(timeoutId) }, - abort: () => abortController.abort(), + // Manual abort is user/client cancellation (disconnect, Stop, registerManualExecutionAborter). + abort: () => abortController.abort(new DOMException('user', 'AbortError')), timeoutMs, } } diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index c8a632e167f..e8ea4071577 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -14,7 +14,6 @@ import { Agent, type Dispatcher, type RequestInit as UndiciRequestInit, - fetch as undiciFetch, request as undiciRequest, } from 'undici' import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' @@ -544,7 +543,7 @@ const MAX_GUARDED_REDIRECTS = 5 * a 3xx to `http://169.254.169.254/` would otherwise connect directly. Hostname * targets are covered by {@link createSsrfGuardedLookup} at connect time. */ -function assertGuardedRedirectTarget(url: URL): void { +function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void { if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error(`Blocked by SSRF policy: redirect to unsupported protocol ${url.protocol}`) } @@ -553,6 +552,16 @@ function assertGuardedRedirectTarget(url: URL): void { ? url.hostname.slice(1, -1) : url.hostname if (ipaddr.isValid(host) && isPrivateOrReservedIP(host)) { + // The pinned-private carve-out permits exactly its own validated IP as a target (a + // self-hosted MCP on a private IP, or a same-host redirect that stays on it) — but nothing + // else private (a redirect to e.g. the cloud metadata IP is still blocked). + if ( + allowedPinnedIp && + ipaddr.isValid(allowedPinnedIp) && + ipaddr.process(host).toString() === ipaddr.process(allowedPinnedIp).toString() + ) { + return + } throw new Error('Blocked by SSRF policy: redirect to a private or reserved address') } } @@ -568,12 +577,15 @@ function assertGuardedRedirectTarget(url: URL): void { export async function followRedirectsGuarded( rawFetch: (url: string, init: UndiciRequestInit) => Promise, input: string, - init: UndiciRequestInit + init: UndiciRequestInit, + options?: { allowRedirectToIp?: string } ): Promise { let currentUrl = new URL(input) - // The initial URL gets the same IP-literal check as redirect hops, so the exported - // guard is self-contained even when a caller skips its own up-front validation. - assertGuardedRedirectTarget(currentUrl) + // The initial URL gets the same IP-literal check as redirect hops, so the exported guard is + // self-contained even when a caller skips its own up-front validation. `allowRedirectToIp` + // (the pinned-private MCP carve-out's validated IP) permits that one private target — both the + // initial URL and any hop that stays on it — while everything else private stays blocked. + assertGuardedRedirectTarget(currentUrl, options?.allowRedirectToIp) let method = (init.method ?? 'GET').toUpperCase() let body = init.body let headers = init.headers @@ -587,7 +599,13 @@ export async function followRedirectsGuarded( }) const status = response.status const location = response.headers.get('location') - if (![301, 302, 303, 307, 308].includes(status) || !location) return response + if (![301, 302, 303, 307, 308].includes(status) || !location) { + // `response.url` is already the final hop's URL (set per-request by the raw fetch); flag + // `redirected` too when at least one hop was followed, matching fetch semantics. + if (hop > 0) + Object.defineProperty(response, 'redirected', { value: true, configurable: true }) + return response + } // Cancel the redirect body up front so the throw paths below (hop cap, blocked // target) can't leave a socket checked out on the long-lived Agent. await response.body?.cancel().catch(() => {}) @@ -595,7 +613,7 @@ export async function followRedirectsGuarded( throw new Error(`Blocked by SSRF policy: more than ${MAX_GUARDED_REDIRECTS} redirects`) } const nextUrl = new URL(location, currentUrl) - assertGuardedRedirectTarget(nextUrl) + assertGuardedRedirectTarget(nextUrl, options?.allowRedirectToIp) // Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET, dropping // the entity headers that described the removed body (a retained Content-Length / // Content-Type on a bodyless GET is malformed and undici rejects it). @@ -781,7 +799,7 @@ function nodeReadableToWebStream(nodeStream: Readable): ReadableStream { let url: string let effectiveInit = init as UndiciRequestInit @@ -880,6 +898,36 @@ async function undiciRequestAsResponse( } } +/** + * Normalizes a `fetch(input, init)` call into a URL string + init. A `Request` input carries + * its own method/headers/body/signal; lift them into the init (explicit init fields win, per + * fetch semantics) so a manual redirect follower can't silently downgrade a POST Request to a + * bare GET or lose its headers. + */ +async function liftFetchArgs( + input: RequestInfo | URL, + init?: RequestInit +): Promise<{ target: string; effectiveInit: RequestInit }> { + const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (typeof Request !== 'undefined' && input instanceof Request) { + const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD' + return { + target, + effectiveInit: { + method: input.method, + headers: input.headers, + body: bodyAllowed ? await input.clone().arrayBuffer() : undefined, + signal: input.signal, + // Carry the Request's redirect mode so the pinned fetch honors `manual`/`error` + // instead of defaulting a `Request({ redirect: 'manual' })` to `follow`. + redirect: input.redirect, + ...init, + }, + } + } + return { target, effectiveInit: init ?? {} } +} + /** * SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled * hosts: DNS resolves normally, and every socket connect validates the chosen @@ -903,21 +951,7 @@ export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher) const guarded = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - // A Request input carries its own method/headers/body/signal; lift them into the - // init (explicit init fields win, per fetch semantics) so the manual redirect - // follower doesn't silently downgrade a guarded POST Request to a bare GET. - let effectiveInit: RequestInit = init ?? {} - if (typeof Request !== 'undefined' && input instanceof Request) { - const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD' - effectiveInit = { - method: input.method, - headers: input.headers, - body: bodyAllowed ? await input.clone().arrayBuffer() : undefined, - signal: input.signal, - ...init, - } - } + const { target, effectiveInit } = await liftFetchArgs(input, init) // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ return followRedirectsGuarded(rawFetch, target, effectiveInit as unknown as UndiciRequestInit) } @@ -977,14 +1011,42 @@ export function createPinnedFetchWithDispatcher( ...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}), }) + const rawFetch = (url: string, init: UndiciRequestInit): Promise => + // double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime + undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher) + + // Requests go through `undici.request` (not `undici.fetch`) because fetch's streaming + // `response.body` never delivers under the Bun runtime the server runs on — the same bug + // {@link createSsrfGuardedFetchWithDispatcher} works around. Redirects are handled here (not + // by a caller's wrapper — the pinned fetch is passed straight to provider/A2A SDKs), honoring + // the request's `redirect` mode: `manual`/`error` must NOT transparently follow (e.g. + // `detectMcpAuthType` inspects the 3xx to classify auth). The default `follow` uses + // {@link followRedirectsGuarded}, which drops headers on cross-origin hops (so a redirect + // can't disclose a provider `api-key` to another origin) and stamps the final `response.url`. + // Every hop still dispatches through the pinned `Agent` (its `connect.lookup` forces + // `resolvedIP`), so a redirect can't escape to another address. const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - // double-cast-allowed: DOM RequestInfo/URL and undici fetch input types differ but are structurally compatible at runtime (Node's global fetch IS undici) - const undiciInput = input as unknown as Parameters[0] + const { target, effectiveInit } = await liftFetchArgs(input, init) + const mode = effectiveInit.redirect ?? 'follow' // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ - const undiciInit: UndiciRequestInit = { ...(init as unknown as UndiciRequestInit), dispatcher } - const response = await undiciFetch(undiciInput, undiciInit) - // double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime - return response as unknown as Response + const undiciInit = effectiveInit as unknown as UndiciRequestInit + if (mode === 'manual') { + return rawFetch(target, undiciInit) + } + if (mode === 'error') { + const response = await rawFetch(target, undiciInit) + const location = response.headers.get('location') + if (response.status >= 300 && response.status < 400 && location) { + await response.body?.cancel().catch(() => {}) + throw new TypeError('Pinned fetch received an unexpected redirect (redirect: "error")') + } + return response + } + // Permit this pinned IP as a redirect/initial target even when it's private (the + // self-hosted MCP carve-out on a private/loopback IP, and same-host redirects that stay on + // it) — otherwise the guarded policy would block a self-hosted server reaching itself. Any + // OTHER private target (e.g. a redirect to the cloud metadata IP) is still blocked. + return followRedirectsGuarded(rawFetch, target, undiciInit, { allowRedirectToIp: resolvedIP }) } return { fetch: pinned, dispatcher } diff --git a/apps/sim/lib/core/security/pinned-fetch.server.test.ts b/apps/sim/lib/core/security/pinned-fetch.server.test.ts index 66c798ad556..a1eca0eb09e 100644 --- a/apps/sim/lib/core/security/pinned-fetch.server.test.ts +++ b/apps/sim/lib/core/security/pinned-fetch.server.test.ts @@ -1,39 +1,33 @@ /** * @vitest-environment node */ +import { Readable } from 'node:stream' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAgent, mockUndiciFetch, capturedAgentOptions, agentCloses } = vi.hoisted(() => { +const { mockAgent, mockUndiciRequest, capturedAgentOptions } = vi.hoisted(() => { const capturedAgentOptions: unknown[] = [] - const agentCloses: unknown[] = [] class MockAgent { constructor(options: unknown) { capturedAgentOptions.push(options) } close() { - agentCloses.push(this) + return Promise.resolve() + } + destroy() { return Promise.resolve() } } return { mockAgent: MockAgent, - mockUndiciFetch: vi.fn(), + mockUndiciRequest: vi.fn(), capturedAgentOptions, - agentCloses, } }) -vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch })) -/** - * Query-suffixed import gives this file a private instance of the module under - * test. Under `isolate: false` the worker's module graph is shared across test - * files, so the plain specifier may already be cached with the real `undici` - * binding (mocks never reach an already-evaluated module) — and evaluating it - * here under this file's mocks would poison it for later files. The suffixed id - * is unique to this file, so it always evaluates fresh with the mocks above. - */ +vi.mock('undici', () => ({ Agent: mockAgent, request: mockUndiciRequest })) + declare module '@/lib/core/security/input-validation.server?pinned-fetch-test' { - // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + // biome-ignore lint/suspicious/noExportsInTest: ambient re-declaration for the query-suffixed specifier export * from '@/lib/core/security/input-validation.server' } @@ -42,12 +36,22 @@ import { createPinnedFetch } from '@/lib/core/security/input-validation.server?p type LookupCallback = (err: Error | null, address: string, family: number) => void type PinnedLookup = (hostname: string, options: { all?: boolean }, callback: LookupCallback) => void +function byteStream(text: string): Readable { + const stream = new Readable({ read() {} }) + stream.push(Buffer.from(text)) + stream.push(null) + return stream +} + +function undiciReply(statusCode: number, headers: Record, body: Readable) { + return { statusCode, headers, body, trailers: {}, opaque: null, context: {} } +} + describe('createPinnedFetch', () => { beforeEach(() => { vi.clearAllMocks() capturedAgentOptions.length = 0 - agentCloses.length = 0 - mockUndiciFetch.mockResolvedValue(new Response('ok')) + mockUndiciRequest.mockResolvedValue(undiciReply(200, {}, byteStream('ok'))) }) it('builds an undici Agent whose pinned lookup always resolves to the validated IP', async () => { @@ -86,7 +90,7 @@ describe('createPinnedFetch', () => { expect(resolved).toEqual({ address: '2606:4700:4700::1111', family: 6 }) }) - it('forwards the pinned dispatcher on every call while preserving init options', async () => { + it('dispatches through the pinned Agent, preserving init', async () => { const pinned = createPinnedFetch('203.0.113.10') const controller = new AbortController() @@ -97,32 +101,117 @@ describe('createPinnedFetch', () => { signal: controller.signal, }) - expect(mockUndiciFetch).toHaveBeenCalledTimes(1) - const [url, init] = mockUndiciFetch.mock.calls[0] + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + const [url, options] = mockUndiciRequest.mock.calls[0] expect(url).toBe('https://myresource.openai.azure.com/openai/v1/responses') - const typedInit = init as RequestInit & { dispatcher?: unknown } - expect(typedInit.dispatcher).toBeInstanceOf(mockAgent) - expect(typedInit.method).toBe('POST') - expect(typedInit.headers).toEqual({ 'api-key': 'secret' }) - expect(typedInit.body).toBe('{}') - expect(typedInit.signal).toBe(controller.signal) + expect(options.dispatcher).toBeInstanceOf(mockAgent) + expect(options.method).toBe('POST') + expect(options.headers).toEqual({ 'api-key': 'secret' }) + expect(options.body).toBe('{}') + expect(options.signal).toBe(controller.signal) }) - it('handles an undefined init by still attaching the dispatcher', async () => { + it('honors redirect: "manual" — returns the 3xx without following (auth-type probe)', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(302, { location: 'https://login.example.com/' }, byteStream('')) + ) const pinned = createPinnedFetch('203.0.113.10') - await pinned('https://example.com') - const init = mockUndiciFetch.mock.calls[0][1] as { dispatcher?: unknown } - expect(init.dispatcher).toBeInstanceOf(mockAgent) + + const response = await pinned('https://mcp.example.com/', { redirect: 'manual' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('https://login.example.com/') + }) + + it('honors redirect mode carried on a Request input (not just init)', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(302, { location: 'https://login.example.com/' }, byteStream('')) + ) + const pinned = createPinnedFetch('203.0.113.10') + + const response = await pinned(new Request('https://mcp.example.com/', { redirect: 'manual' })) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + expect(response.status).toBe(302) + }) + + it('follows redirects by default and DROPS headers on a cross-origin hop (no api-key leak)', async () => { + mockUndiciRequest + .mockResolvedValueOnce( + undiciReply(307, { location: 'https://other-origin.example/final' }, byteStream('')) + ) + .mockResolvedValueOnce(undiciReply(200, {}, byteStream('done'))) + const pinned = createPinnedFetch('203.0.113.10') + + const response = await pinned('https://azure.example.com/v1/responses', { + method: 'GET', + headers: { 'api-key': 'secret' }, + }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(2) + // Second (cross-origin) hop must not carry the provider credential — no headers forwarded. + const secondHopHeaders = (mockUndiciRequest.mock.calls[1][1].headers ?? {}) as Record< + string, + string + > + expect(secondHopHeaders['api-key']).toBeUndefined() + expect(Object.keys(secondHopHeaders)).toHaveLength(0) + expect(response.status).toBe(200) + expect(response.url).toBe('https://other-origin.example/final') + expect(response.redirected).toBe(true) + expect(await response.text()).toBe('done') + }) + + it('does NOT block a private IP-literal URL (self-hosted-private MCP carve-out)', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('mcp'))) + const pinned = createPinnedFetch('10.0.0.5') + + // A self-hosted MCP configured with a private IP-literal URL must still connect — the old + // undici.fetch path never ran the SSRF initial-target check that would otherwise block it. + const response = await pinned('http://10.0.0.5:3000/mcp', { method: 'POST', body: '{}' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + expect(response.status).toBe(200) + expect(await response.text()).toBe('mcp') + }) + + it('follows a redirect that stays on the pinned private IP (self-hosted MCP alias)', async () => { + mockUndiciRequest + .mockResolvedValueOnce( + undiciReply(301, { location: 'http://10.0.0.5:3000/mcp/' }, byteStream('')) + ) + .mockResolvedValueOnce(undiciReply(200, {}, byteStream('mcp'))) + const pinned = createPinnedFetch('10.0.0.5') + + const response = await pinned('http://10.0.0.5:3000/mcp', { method: 'GET' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(2) + expect(response.status).toBe(200) + expect(await response.text()).toBe('mcp') + }) + + it('STILL blocks a redirect to a different private IP (no metadata-IP escape)', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(302, { location: 'http://169.254.169.254/latest/meta-data/' }, byteStream('')) + ) + const pinned = createPinnedFetch('10.0.0.5') + + await expect(pinned('http://10.0.0.5:3000/mcp', { method: 'GET' })).rejects.toThrow( + /private or reserved/ + ) + // The initial request happened; the redirect to the metadata IP was refused. + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) }) - it('reuses one captured dispatcher across all calls of a single instance', async () => { + it('reuses one dispatcher across all calls of a single instance', async () => { const pinned = createPinnedFetch('203.0.113.10') await pinned('https://example.com/a') await pinned('https://example.com/b') expect(capturedAgentOptions).toHaveLength(1) - const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher - const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher + const d1 = (mockUndiciRequest.mock.calls[0][1] as { dispatcher: unknown }).dispatcher + const d2 = (mockUndiciRequest.mock.calls[1][1] as { dispatcher: unknown }).dispatcher expect(d1).toBe(d2) }) @@ -133,13 +222,13 @@ describe('createPinnedFetch', () => { await b('https://example.com/b') expect(capturedAgentOptions).toHaveLength(2) - const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher - const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher + const d1 = (mockUndiciRequest.mock.calls[0][1] as { dispatcher: unknown }).dispatcher + const d2 = (mockUndiciRequest.mock.calls[1][1] as { dispatcher: unknown }).dispatcher expect(d1).not.toBe(d2) }) - it('returns the response produced by undici fetch', async () => { - mockUndiciFetch.mockResolvedValueOnce(new Response('pong', { status: 201 })) + it('returns a streaming Response built from the undici.request body', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(201, {}, byteStream('pong'))) const pinned = createPinnedFetch('203.0.113.10') const response = await pinned('https://example.com') expect(response.status).toBe(201) diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts index 0fc2c8dc2f8..b5ae09c1418 100644 --- a/apps/sim/lib/credentials/access.test.ts +++ b/apps/sim/lib/credentials/access.test.ts @@ -8,6 +8,11 @@ const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, + resolveWorkspaceAccess: vi.fn(async (workspaceId: string, userId: string, provided?: any) => + provided && provided.workspace?.id === workspaceId + ? provided + : mockCheckWorkspaceAccess(workspaceId, userId) + ), })) import { getCredentialActorContext } from '@/lib/credentials/access' diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 1fd5728982a..005a47fe65d 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { credential, credentialMember, credentialTypeEnum } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { resolveWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect @@ -74,11 +74,11 @@ export async function getCredentialActorContext( } } - const providedAccess = options?.workspaceAccess - const workspaceAccess = - providedAccess && providedAccess.workspace?.id === credentialRow.workspaceId - ? providedAccess - : await checkWorkspaceAccess(credentialRow.workspaceId, userId) + const workspaceAccess = await resolveWorkspaceAccess( + credentialRow.workspaceId, + userId, + options?.workspaceAccess + ) const [memberRow] = await db .select() .from(credentialMember) @@ -111,6 +111,13 @@ export async function getCredentialActorContext( * Workspace owners and admins are derived credential admins, so no per-credential * owner promotion is needed to avoid orphaning a credential. Returns the number * of memberships revoked. + * + * Deliberately DIVERGES from the skill sibling (`removeWorkspaceSkillMembershipsTx` + * deletes active rows): credential access is explicit-rows-only, so a revoked + * row here is an inert tombstone the env-credential join sync uses to avoid + * resurrecting access. Skills have an implicit workspace-shared grant, where a + * revoked row is a live per-skill DENY marker and a kept-active row would + * re-grant on rejoin — do not "harmonize" either helper toward the other. */ export async function revokeWorkspaceCredentialMembershipsTx( tx: DbOrTx, diff --git a/apps/sim/lib/execution/e2b.test.ts b/apps/sim/lib/execution/e2b.test.ts deleted file mode 100644 index d5d45fbd03a..00000000000 --- a/apps/sim/lib/execution/e2b.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * @vitest-environment node - */ -import { resetEnvMock, setEnv } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -beforeAll(() => { - setEnv({ E2B_API_KEY: 'test-key' }) -}) - -afterAll(resetEnvMock) - -import { CodeLanguage } from '@/lib/execution/languages' - -const { mockCreate, mockRunCode, mockCommandsRun, mockFilesWrite, mockKill } = vi.hoisted(() => ({ - mockCreate: vi.fn(), - mockRunCode: vi.fn(), - mockCommandsRun: vi.fn(), - mockFilesWrite: vi.fn(), - mockKill: vi.fn(), -})) - -vi.mock('@e2b/code-interpreter', () => ({ Sandbox: { create: mockCreate } })) - -import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' - -describe('e2b sandbox inputs', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCreate.mockResolvedValue({ - sandboxId: 'sb_1', - files: { write: mockFilesWrite }, - commands: { run: mockCommandsRun }, - runCode: mockRunCode, - kill: mockKill, - }) - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: [], stderr: [] }, - results: [], - }) - // Default: shell code run + any fetch succeed. - mockCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) - }) - - it('fetches a url entry via curl with URL/DST/DIR passed as envs (no inline write)', async () => { - await executeInE2B({ - code: 'x', - language: CodeLanguage.JavaScript, - timeoutMs: 1000, - sandboxFiles: [ - { type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p?a=1&b=2' }, - ], - }) - - expect(mockCommandsRun).toHaveBeenCalledTimes(1) - const [cmd, opts] = mockCommandsRun.mock.calls[0] - expect(cmd).toContain('curl') - expect(cmd).toContain('mkdir -p') - // URL/path go through envs, never interpolated into the command string. - expect(cmd).not.toContain('https://s3.example') - expect(opts.envs).toEqual({ - URL: 'https://s3.example/p?a=1&b=2', - DST: '/home/user/tables/t.csv', - DIR: '/home/user/tables', - }) - expect(opts.user).toBeUndefined() // code sandbox runs as default user - expect(mockFilesWrite).not.toHaveBeenCalled() - }) - - it('writes a content entry inline (no fetch)', async () => { - await executeInE2B({ - code: 'x', - language: CodeLanguage.JavaScript, - timeoutMs: 1000, - sandboxFiles: [{ path: '/home/user/f.txt', content: 'hi' }], - }) - - expect(mockFilesWrite).toHaveBeenCalledWith('/home/user/f.txt', 'hi') - expect(mockCommandsRun).not.toHaveBeenCalled() - }) - - it('fetches as root in the shell sandbox', async () => { - await executeShellInE2B({ - code: 'echo hi', - envs: {}, - timeoutMs: 1000, - sandboxFiles: [{ type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p' }], - }) - - const fetchCall = mockCommandsRun.mock.calls.find((c) => c[1]?.envs?.URL) - expect(fetchCall).toBeDefined() - expect(fetchCall?.[0]).toContain('curl') - expect(fetchCall?.[1].user).toBe('root') - }) - - it('passes the requested timeout to shell execution and returns timeout failures', async () => { - mockCommandsRun.mockRejectedValueOnce( - Object.assign(new Error('Execution timed out'), { - stderr: 'Execution timed out', - exitCode: 1, - }) - ) - - const result = await executeShellInE2B({ - code: 'sleep 60', - envs: {}, - timeoutMs: 7000, - }) - - expect(mockCommandsRun).toHaveBeenCalledWith( - 'sleep 60', - expect.objectContaining({ timeoutMs: 7000 }) - ) - expect(result.error).toContain('Execution timed out') - expect(mockKill).toHaveBeenCalled() - }) - - it('throws a clear error and kills the sandbox when the fetch fails', async () => { - mockCommandsRun.mockRejectedValueOnce(new Error('curl: (22) 403')) - - await expect( - executeInE2B({ - code: 'x', - language: CodeLanguage.JavaScript, - timeoutMs: 1000, - sandboxFiles: [ - { type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p' }, - ], - }) - ).rejects.toThrow(/Failed to fetch mounted file into sandbox/) - - expect(mockKill).toHaveBeenCalled() - expect(mockRunCode).not.toHaveBeenCalled() - }) -}) - -describe('e2b result marker extraction', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCreate.mockResolvedValue({ - sandboxId: 'sb_1', - files: { write: mockFilesWrite }, - commands: { run: mockCommandsRun }, - runCode: mockRunCode, - kill: mockKill, - }) - mockCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) - }) - - it('parses the result marker from a single stdout entry', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: ['before\n', `__SIM_RESULT__=${JSON.stringify({ ok: true })}\n`] }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toEqual({ ok: true }) - expect(res.stdout).toBe('before') - }) - - it('reassembles a marker line split across stream chunks (large single-line result)', async () => { - const payload = 'x'.repeat(50_000) - const markerLine = `__SIM_RESULT__=${JSON.stringify(payload)}\n` - // The kernel splits one long line across several stream messages; each - // chunk is NOT newline-terminated except the last. - const chunks = [ - 'log line\n', - markerLine.slice(0, 20_000), - markerLine.slice(20_000, 40_000), - markerLine.slice(40_000), - ] - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: chunks }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toBe(payload) - expect(res.stdout).toBe('log line') - }) - - it('returns an error instead of a truncated fragment when the marker payload is corrupted', async () => { - // A genuinely broken payload (e.g. the tail chunk was lost entirely). - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: [`__SIM_RESULT__="${'x'.repeat(100)}\n`] }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toMatch(/corrupted in transport/) - expect(res.result).toBeNull() - }) - - it('parses the shell marker and falls back to the raw string for non-JSON payloads', async () => { - mockCommandsRun.mockResolvedValueOnce({ - stdout: `hello\n__SIM_RESULT__=${JSON.stringify([1, 2])}\n`, - stderr: '', - exitCode: 0, - }) - const ok = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) - expect(ok.error).toBeUndefined() - expect(ok.result).toEqual([1, 2]) - expect(ok.stdout).toBe('hello') - - // Shell markers are user-authored (`echo "__SIM_RESULT__=$STATUS"`), so a - // plain non-JSON value is a string result, never a transport error. - mockCommandsRun.mockResolvedValueOnce({ - stdout: '__SIM_RESULT__=ok\n', - stderr: '', - exitCode: 0, - }) - const plain = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) - expect(plain.error).toBeUndefined() - expect(plain.result).toBe('ok') - }) - - it('takes the LAST marker line so user-printed marker lines cannot shadow the real result', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { - stdout: [ - '__SIM_RESULT__=user-debug-junk\n', - `__SIM_RESULT__=${JSON.stringify({ real: true })}\n`, - ], - }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toEqual({ real: true }) - expect(res.stdout).not.toContain('__SIM_RESULT__') - }) - - it('finds a marker that landed on stderr', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { - stdout: ['regular output\n'], - stderr: [`__SIM_RESULT__=${JSON.stringify([1])}\n`], - }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toEqual([1]) - expect(res.stdout).not.toContain('__SIM_RESULT__') - }) - - it('keeps separate print lines intact (chunks concatenated verbatim, not newline-joined)', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: ['a\n', 'b\n'], stderr: ['warn\n'] }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.result).toBeNull() - expect(res.stdout).toBe('a\nb\n\nwarn\n') - }) -}) diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts deleted file mode 100644 index 0f9f2e3a553..00000000000 --- a/apps/sim/lib/execution/e2b.ts +++ /dev/null @@ -1,516 +0,0 @@ -import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { env } from '@/lib/core/config/env' -import { CodeLanguage } from '@/lib/execution/languages' - -/** - * A sandbox input file. `content` entries are written inline; `url` entries are fetched from inside - * the sandbox (so large mounts never pass their bytes through the web process). - */ -export type SandboxFile = - | { type?: 'content'; path: string; content: string; encoding?: 'base64' } - | { type: 'url'; path: string; url: string } - -export interface E2BExecutionRequest { - code: string - language: CodeLanguage - timeoutMs: number - sandboxFiles?: SandboxFile[] - outputSandboxPath?: string - outputSandboxPaths?: string[] - // Which sandbox template to run in. Defaults to 'code' (mothership-shell). - // Document generation passes 'doc' so it runs in the doc template - // (mothership-docs) that has python-pptx/docx/openpyxl/reportlab installed. - sandboxKind?: 'code' | 'doc' -} - -export interface E2BShellExecutionRequest { - code: string - envs: Record - timeoutMs: number - sandboxFiles?: SandboxFile[] - outputSandboxPath?: string - outputSandboxPaths?: string[] - // Which sandbox template to run in. Defaults to 'shell' (mothership-shell). - // The Node document engines (pptxgenjs/docx + react-icons/sharp) pass 'doc' so - // they run in the doc template (mothership-docs). - sandboxKind?: 'shell' | 'doc' -} - -export interface E2BExecutionResult { - result: unknown - stdout: string - sandboxId?: string - error?: string - exportedFileContent?: string - exportedFiles?: Record - /** Base64-encoded PNG images captured from rich outputs (e.g. matplotlib figures). */ - images?: string[] -} - -const logger = createLogger('E2BExecution') - -/** - * Materializes sandbox input files before user code runs. `content` entries are written inline; - * `url` entries are fetched from inside the sandbox via `curl` — their bytes never pass through the - * web process, so the mount size is bounded by sandbox disk, not web heap. The URL and paths are - * passed as env vars (never interpolated into the shell) so a presigned query string can't break or - * inject. A failed fetch throws so user code never runs against a missing mount. `rootUser` matches - * the shell sandbox's root execution context. - */ -async function writeSandboxInputs( - sandbox: E2BSandbox, - files: SandboxFile[] | undefined, - opts: { sandboxId?: string; rootUser?: boolean } -): Promise { - if (!files?.length) return - const fetchedByUrl: string[] = [] - const writtenInline: string[] = [] - for (const file of files) { - if (file.type === 'url') { - const dir = file.path.slice(0, file.path.lastIndexOf('/')) - try { - await sandbox.commands.run( - 'set -e; [ -n "$DIR" ] && mkdir -p "$DIR"; curl -fsS --retry 3 --retry-connrefused --max-time 300 "$URL" -o "$DST"', - { - envs: { URL: file.url, DST: file.path, DIR: dir }, - ...(opts.rootUser ? { user: 'root' } : {}), - } - ) - fetchedByUrl.push(file.path) - } catch (error) { - throw new Error( - `Failed to fetch mounted file into sandbox at ${file.path}: ${getErrorMessage(error)}` - ) - } - } else if (file.encoding === 'base64') { - const buf = Buffer.from(file.content, 'base64') - await sandbox.files.write( - file.path, - buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) - ) - writtenInline.push(file.path) - } else { - await sandbox.files.write(file.path, file.content) - writtenInline.push(file.path) - } - } - // Split counts so it's visible whether a mount was fetched in-sandbox (by presigned URL, no bytes - // through the web process) or written inline. - logger.info('Materialized sandbox inputs', { - sandboxId: opts.sandboxId, - fetchedByUrlCount: fetchedByUrl.length, - writtenInlineCount: writtenInline.length, - fetchedByUrl, - writtenInline, - }) -} - -async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise { - const apiKey = env.E2B_API_KEY - if (!apiKey) { - throw new Error('E2B_API_KEY is required when E2B is enabled') - } - - // Document generation uses a dedicated template (python-pptx/docx/openpyxl/ - // reportlab + fonts); shell/code execution use the general shell template. - // Doc fails closed: never run LLM-authored Python in E2B's default template - // (which is not vetted for this) just because the doc template id is unset. - if (kind === 'doc' && !env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) { - throw new Error('Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)') - } - // Pi fails closed for the same reason: the coding agent needs the Pi CLI + git - // baked into a vetted template, never E2B's default image. - if (kind === 'pi' && !env.E2B_PI_TEMPLATE_ID) { - throw new Error('Pi cloud agent not configured (E2B_PI_TEMPLATE_ID is unset)') - } - - const templateName = - kind === 'doc' - ? env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID - : kind === 'pi' - ? env.E2B_PI_TEMPLATE_ID - : env.MOTHERSHIP_E2B_TEMPLATE_ID - logger.info('Creating E2B sandbox', { - kind, - template: templateName || '(default)', - }) - const { Sandbox } = await import('@e2b/code-interpreter') - return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey }) -} - -/** - * Marker prefix for the serialized code result printed to stdout. Emitters - * (the wrapper builders in the function-execute route) interpolate this - * constant so producer and parser cannot drift. - */ -export const SIM_RESULT_PREFIX = '__SIM_RESULT__=' - -/** - * Extracts the `__SIM_RESULT__=` marker line from stdout and parses its JSON - * payload. Takes the LAST marker line: the wrapper prints its marker after all - * user output, so an earlier user-printed line with the same prefix (debug - * output, a grepped log) never shadows the real result. `parseFailed` means - * the last marker's payload was not valid JSON — `rawPayload` carries it so - * callers whose markers are user-authored (shell) can fall back to the plain - * string, while wrapper-backed callers treat it as transport corruption. - */ -function extractSimResult(stdout: string): { - result: unknown - cleanedStdout: string - parseFailed: boolean - rawPayload?: string -} { - const lines = stdout.split('\n') - let markerIndex = -1 - for (let i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith(SIM_RESULT_PREFIX)) { - markerIndex = i - break - } - } - if (markerIndex === -1) { - return { result: null, cleanedStdout: stdout, parseFailed: false } - } - const rawPayload = lines[markerIndex].slice(SIM_RESULT_PREFIX.length) - let result: unknown = null - let parseFailed = false - try { - result = JSON.parse(rawPayload) - } catch { - parseFailed = true - } - const filteredLines = lines.filter((l) => !l.startsWith(SIM_RESULT_PREFIX)) - if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { - filteredLines.pop() - } - return { result, cleanedStdout: filteredLines.join('\n'), parseFailed, rawPayload } -} - -const SIM_RESULT_CORRUPTED_ERROR = - 'Sandbox result was corrupted in transport (the __SIM_RESULT__ line failed to parse). ' + - "Do not trust or persist this call's output. For large results, write the content to a " + - 'file inside the sandbox and export it via outputs.files[].sandboxPath instead of returning it.' - -function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { - const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() - const binaryExts = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.pdf', - '.zip', - '.mp3', - '.mp4', - '.docx', - '.pptx', - '.xlsx', - ]) - return binaryExts.has(ext) -} - -async function readSandboxOutputFile( - sandbox: E2BSandbox, - outputSandboxPath: string, - options?: { user?: string } -): Promise { - try { - if (shouldReadSandboxPathAsBase64(outputSandboxPath)) { - const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, options) - return b64Result.stdout - } - return await sandbox.files.read(outputSandboxPath) - } catch (error) { - logger.warn('Failed to read requested sandbox output file', { - outputSandboxPath, - error: getErrorMessage(error), - }) - return undefined - } -} - -function requestedOutputSandboxPaths(req: { - outputSandboxPath?: string - outputSandboxPaths?: string[] -}): string[] { - const paths = [...(req.outputSandboxPaths ?? [])] - if (req.outputSandboxPath && !paths.includes(req.outputSandboxPath)) { - paths.push(req.outputSandboxPath) - } - return paths -} - -export async function executeInE2B(req: E2BExecutionRequest): Promise { - const { code, language, timeoutMs } = req - - const sandbox = await createE2BSandbox(req.sandboxKind ?? 'code') - const sandboxId = sandbox.sandboxId - - try { - // Inside the try so a failed mount still kills the sandbox via the finally below. - await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId }) - - const execution = await sandbox.runCode(code, { - language: language === CodeLanguage.Python ? 'python' : 'javascript', - timeoutMs, - }) - - if (execution.error) { - const errorMessage = `${execution.error.name}: ${execution.error.value}` - logger.error(`E2B execution error`, { - sandboxId, - error: execution.error, - errorMessage, - }) - - const errorOutput = execution.error.traceback || errorMessage - return { - result: null, - stdout: errorOutput, - error: errorMessage, - sandboxId, - } - } - - // Kernel stream entries are chunks, not lines — each already carries its own - // newlines, and one long line can arrive split across several entries. - // Concatenate each stream verbatim: joining chunks with '\n' injected a - // newline at every chunk boundary, which corrupted large single-line - // __SIM_RESULT__ payloads and silently truncated the persisted result. - // Distinct sources (final-expression text, stdout, stderr) still join with - // '\n' so the marker is found no matter which stream carried it. - const streamStdout = (execution.logs?.stdout ?? []).join('') - const streamStderr = (execution.logs?.stderr ?? []).join('') - const combinedOutput = [execution.text, streamStdout, streamStderr].filter(Boolean).join('\n') - - const extraction = extractSimResult(combinedOutput) - const cleanedStdout = extraction.cleanedStdout - - // The wrapper always emits valid single-line JSON, so a marker that fails - // to parse means the payload was mangled in transport — never persist it. - if (extraction.parseFailed) { - logger.error('E2B result marker failed to parse', { - sandboxId, - stdoutEntryCount: execution.logs?.stdout?.length ?? 0, - stdoutLength: streamStdout.length, - }) - return { - result: null, - stdout: cleanedStdout, - error: SIM_RESULT_CORRUPTED_ERROR, - sandboxId, - } - } - const result = extraction.result - - const images: string[] = [] - if (execution.results?.length) { - for (const r of execution.results) { - if (r.png) { - images.push(r.png) - } else if (r.jpeg) { - images.push(r.jpeg) - } - } - } - - const exportedFiles: Record = {} - for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { - const content = await readSandboxOutputFile(sandbox, outputSandboxPath) - if (content !== undefined) { - exportedFiles[outputSandboxPath] = content - } - } - const exportedFileContent = req.outputSandboxPath - ? exportedFiles[req.outputSandboxPath] - : undefined - - return { - result, - stdout: cleanedStdout, - sandboxId, - exportedFileContent, - exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined, - images: images.length ? images : undefined, - } - } finally { - try { - await sandbox.kill() - } catch {} - } -} - -export async function executeShellInE2B( - req: E2BShellExecutionRequest -): Promise { - const { code, envs, timeoutMs } = req - - const sandbox = await createE2BSandbox(req.sandboxKind ?? 'shell') - const sandboxId = sandbox.sandboxId - - try { - // Inside the try so a failed mount still kills the sandbox via the finally below. - await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId, rootUser: true }) - - let result: { stdout: string; stderr: string; exitCode: number } - try { - result = await sandbox.commands.run(code, { - envs: { - ...envs, - PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin', - }, - timeoutMs, - user: 'root', - }) - } catch (cmdError: any) { - const stderr = cmdError?.stderr || cmdError?.message || String(cmdError) - const stdout = cmdError?.stdout || '' - const exitCode = cmdError?.exitCode ?? 1 - logger.error('E2B shell command error', { - sandboxId, - exitCode, - error: stderr.slice(0, 500), - }) - return { - result: null, - stdout: [stdout, stderr].filter(Boolean).join('\n'), - error: stderr || `Command failed with exit code ${exitCode}`, - sandboxId, - } - } - - const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n') - - if (result.exitCode !== 0) { - const errorMessage = result.stderr || `Process exited with code ${result.exitCode}` - logger.error('E2B shell execution error', { - sandboxId, - exitCode: result.exitCode, - stderr: result.stderr?.slice(0, 500), - }) - return { - result: null, - stdout, - error: errorMessage, - sandboxId, - } - } - - // Shell scripts have no wrapper: any __SIM_RESULT__ line is user-authored - // (e.g. `echo "__SIM_RESULT__=$STATUS"`), so a non-JSON payload is a plain - // string result, not transport corruption. commands.run also accumulates - // output into one string, so the chunk-split corruption cannot occur here. - const extraction = extractSimResult(stdout) - const parsed = extraction.parseFailed ? extraction.rawPayload : extraction.result - const cleanedStdout = extraction.cleanedStdout - - const exportedFiles: Record = {} - for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { - const content = await readSandboxOutputFile(sandbox, outputSandboxPath, { - user: 'root', - }) - if (content !== undefined) { - exportedFiles[outputSandboxPath] = content - } - } - const exportedFileContent = req.outputSandboxPath - ? exportedFiles[req.outputSandboxPath] - : undefined - - return { - result: parsed, - stdout: cleanedStdout, - sandboxId, - exportedFileContent, - exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined, - } - } finally { - try { - await sandbox.kill() - } catch {} - } -} - -const PI_SANDBOX_PATH = - '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin' - -/** Result of one command run inside a Pi sandbox. */ -export interface PiSandboxCommandResult { - stdout: string - stderr: string - exitCode: number -} - -/** Runs commands and moves files inside a live Pi sandbox. */ -export interface PiSandboxRunner { - run( - command: string, - options: { - envs?: Record - timeoutMs: number - onStdout?: (chunk: string) => void - onStderr?: (chunk: string) => void - } - ): Promise - readFile(path: string): Promise - /** - * Writes a file via the sandbox filesystem API. Bytes go through the E2B SDK, - * never a shell, so untrusted content (the assembled prompt, a commit message) - * is delivered without any shell parsing — callers reference it by a fixed path. - */ - writeFile(path: string, content: string): Promise -} - -/** - * Creates a Pi sandbox, keeps it alive for the duration of `fn` (so the cloned - * repo persists across the clone -> agent -> push commands), streams command - * output, and always kills the sandbox afterward. Per-command envs are isolated, - * so secrets handed to one command never leak into the next. - */ -export async function withPiSandbox(fn: (runner: PiSandboxRunner) => Promise): Promise { - const sandbox = await createE2BSandbox('pi') - const sandboxId = sandbox.sandboxId - logger.info('Started Pi sandbox', { sandboxId }) - - const runner: PiSandboxRunner = { - run: async (command, options) => { - try { - const result = await sandbox.commands.run(command, { - envs: { ...(options.envs ?? {}), PATH: PI_SANDBOX_PATH }, - timeoutMs: options.timeoutMs, - user: 'root', - onStdout: options.onStdout, - onStderr: options.onStderr, - }) - return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode } - } catch (error) { - const failure = error as { - stdout?: string - stderr?: string - message?: string - exitCode?: number - } - return { - stdout: failure.stdout ?? '', - stderr: failure.stderr ?? failure.message ?? getErrorMessage(error), - exitCode: failure.exitCode ?? 1, - } - } - }, - readFile: (path) => sandbox.files.read(path), - writeFile: async (path, content) => { - await sandbox.files.write(path, content) - }, - } - - try { - return await fn(runner) - } finally { - try { - await sandbox.kill() - } catch {} - } -} diff --git a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts new file mode 100644 index 00000000000..1e8edb89192 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts @@ -0,0 +1,455 @@ +/** + * @vitest-environment node + * + * Provider conformance: the same input must produce the same + * `SandboxExecutionResult` on E2B and on Daytona. A divergence here is exactly + * what would surface as a broken failover mid-incident, so every scenario runs + * twice — once per provider — from a single table. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CodeLanguage } from '@/lib/execution/languages' + +const { + mockEnv, + mockE2BCreate, + mockE2BRunCode, + mockE2BCommandsRun, + mockE2BFilesRead, + mockE2BFilesWrite, + mockE2BKill, + mockDaytonaCreate, + mockInterpreterRunCode, + mockProcessCodeRun, + mockExecuteCommand, + mockUploadFile, + mockDownloadFile, + mockDelete, + mockCreateSession, + mockExecuteSessionCommand, + mockGetSessionCommandLogs, + mockGetSessionCommand, + mockDeleteSession, +} = vi.hoisted(() => ({ + mockEnv: { + SANDBOX_PROVIDER: 'e2b' as string | undefined, + E2B_API_KEY: 'test-key', + MOTHERSHIP_E2B_TEMPLATE_ID: 'mothership-shell', + MOTHERSHIP_E2B_DOC_TEMPLATE_ID: 'mothership-docs', + E2B_PI_TEMPLATE_ID: 'sim-pi', + DAYTONA_API_KEY: 'test-key', + DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1' as string | undefined, + DAYTONA_DOC_SNAPSHOT_ID: 'mothership-docs:v1' as string | undefined, + DAYTONA_PI_SNAPSHOT_ID: 'sim-pi:v1' as string | undefined, + }, + mockE2BCreate: vi.fn(), + mockE2BRunCode: vi.fn(), + mockE2BCommandsRun: vi.fn(), + mockE2BFilesRead: vi.fn(), + mockE2BFilesWrite: vi.fn(), + mockE2BKill: vi.fn(), + mockDaytonaCreate: vi.fn(), + mockInterpreterRunCode: vi.fn(), + mockProcessCodeRun: vi.fn(), + mockExecuteCommand: vi.fn(), + mockUploadFile: vi.fn(), + mockDownloadFile: vi.fn(), + mockDelete: vi.fn(), + mockCreateSession: vi.fn(), + mockExecuteSessionCommand: vi.fn(), + mockGetSessionCommandLogs: vi.fn(), + mockGetSessionCommand: vi.fn(), + mockDeleteSession: vi.fn(), +})) + +vi.mock('@e2b/code-interpreter', () => ({ Sandbox: { create: mockE2BCreate } })) +vi.mock('@daytonaio/sdk', () => ({ + Daytona: class { + create = mockDaytonaCreate + }, +})) +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) + +import { + executeInSandbox, + executeShellInSandbox, + SIM_RESULT_PREFIX, + withPiSandbox, +} from '@/lib/execution/remote-sandbox' + +type Provider = 'e2b' | 'daytona' +const PROVIDERS: Provider[] = ['e2b', 'daytona'] + +/** Points the shared layer at one provider via the SANDBOX_PROVIDER env var. */ +function useProvider(provider: Provider) { + mockEnv.SANDBOX_PROVIDER = provider +} + +/** Stubs a code execution that prints `stdout` and emits `result` via the marker. */ +function stubCodeRun(provider: Provider, stdout: string) { + if (provider === 'e2b') { + mockE2BRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: [stdout], stderr: [] }, + results: [], + }) + } else { + mockInterpreterRunCode.mockResolvedValue({ stdout, stderr: '', error: undefined }) + } +} + +beforeEach(() => { + vi.clearAllMocks() + + mockE2BCreate.mockResolvedValue({ + sandboxId: 'sb_1', + runCode: mockE2BRunCode, + commands: { run: mockE2BCommandsRun }, + files: { read: mockE2BFilesRead, write: mockE2BFilesWrite }, + kill: mockE2BKill, + }) + mockE2BCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) + + mockDaytonaCreate.mockResolvedValue({ + id: 'sb_1', + codeInterpreter: { runCode: mockInterpreterRunCode }, + process: { + codeRun: mockProcessCodeRun, + executeCommand: mockExecuteCommand, + createSession: mockCreateSession, + executeSessionCommand: mockExecuteSessionCommand, + getSessionCommandLogs: mockGetSessionCommandLogs, + getSessionCommand: mockGetSessionCommand, + deleteSession: mockDeleteSession, + }, + fs: { uploadFile: mockUploadFile, downloadFile: mockDownloadFile }, + delete: mockDelete, + }) + mockExecuteCommand.mockResolvedValue({ result: '', exitCode: 0 }) + mockExecuteSessionCommand.mockResolvedValue({ cmdId: 'cmd_1' }) + mockGetSessionCommand.mockResolvedValue({ exitCode: 0 }) +}) + +describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { + beforeEach(() => useProvider(provider)) + + it('parses the __SIM_RESULT__ marker and strips it from stdout', async () => { + stubCodeRun(provider, `hello\n${SIM_RESULT_PREFIX}{"ok":true}`) + + const res = await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.result).toEqual({ ok: true }) + expect(res.stdout).toBe('hello') + expect(res.error).toBeUndefined() + }) + + it('takes the LAST marker so user output cannot shadow the real result', async () => { + stubCodeRun( + provider, + `${SIM_RESULT_PREFIX}{"decoy":true}\nnoise\n${SIM_RESULT_PREFIX}{"real":true}` + ) + + const res = await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.result).toEqual({ real: true }) + }) + + it('refuses to return a corrupted marker payload', async () => { + stubCodeRun(provider, `${SIM_RESULT_PREFIX}{"truncated":`) + + const res = await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.result).toBeNull() + expect(res.error).toContain('corrupted in transport') + }) + + it('survives a large single-line payload without chunk corruption', async () => { + const blob = 'x'.repeat(200_000) + stubCodeRun(provider, `${SIM_RESULT_PREFIX}${JSON.stringify({ blob })}`) + + const res = await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect((res.result as { blob: string }).blob).toHaveLength(200_000) + }) + + it('normalizes execution errors to the same shape', async () => { + if (provider === 'e2b') { + mockE2BRunCode.mockResolvedValue({ + error: { name: 'ValueError', value: 'boom', traceback: 'Traceback...\nValueError: boom' }, + text: '', + logs: { stdout: [], stderr: [] }, + }) + } else { + mockInterpreterRunCode.mockResolvedValue({ + stdout: '', + stderr: '', + error: { name: 'ValueError', value: 'boom', traceback: 'Traceback...\nValueError: boom' }, + }) + } + + const res = await executeInSandbox({ + code: 'raise ValueError("boom")', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBe('ValueError: boom') + expect(res.stdout).toContain('ValueError: boom') + expect(res.result).toBeNull() + }) + + it('fetches url-mounted files inside the sandbox and never inlines the url', async () => { + stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`) + + await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + sandboxFiles: [ + { type: 'url', path: '/mnt/data/in.csv', url: 'https://example/presigned?x=1' }, + ], + }) + + const [command, options] = + provider === 'e2b' ? mockE2BCommandsRun.mock.calls[0] : mockExecuteCommand.mock.calls[0] + expect(command).toContain('curl') + // The presigned URL must travel as an env var, never interpolated into the shell. + expect(command).not.toContain('presigned') + const envs = provider === 'e2b' ? options.envs : mockExecuteCommand.mock.calls[0][2] + expect(envs).toMatchObject({ URL: 'https://example/presigned?x=1', DST: '/mnt/data/in.csv' }) + }) + + it('throws rather than running user code against a missing mount', async () => { + if (provider === 'e2b') { + mockE2BCommandsRun.mockRejectedValue(new Error('curl: (22) 404')) + } else { + mockExecuteCommand.mockResolvedValue({ result: 'curl: (22) 404', exitCode: 22 }) + } + + await expect( + executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + sandboxFiles: [{ type: 'url', path: '/mnt/data/in.csv', url: 'https://example/f' }], + }) + ).rejects.toThrow(/Failed to fetch mounted file/) + }) + + it('treats a non-JSON shell marker as a plain string, not corruption', async () => { + const stdout = `${SIM_RESULT_PREFIX}PLAIN_STATUS` + if (provider === 'e2b') { + mockE2BCommandsRun.mockResolvedValue({ stdout, stderr: '', exitCode: 0 }) + } else { + mockExecuteCommand.mockResolvedValue({ result: stdout, exitCode: 0 }) + } + + const res = await executeShellInSandbox({ code: 'echo hi', envs: {}, timeoutMs: 1000 }) + + expect(res.result).toBe('PLAIN_STATUS') + expect(res.error).toBeUndefined() + }) + + it('surfaces the real command output as the error, not a generic message', async () => { + // E2B populates stderr; Daytona merges both streams into stdout with an empty + // stderr, so the error must fall back to stdout or Daytona failures would read + // as a generic "Process exited with code N". + if (provider === 'e2b') { + mockE2BCommandsRun.mockResolvedValue({ stdout: '', stderr: 'boom detail', exitCode: 3 }) + } else { + mockExecuteCommand.mockResolvedValue({ result: 'boom detail', exitCode: 3 }) + } + + const res = await executeShellInSandbox({ code: 'false', envs: {}, timeoutMs: 1000 }) + + expect(res.result).toBeNull() + expect(res.error).toContain('boom detail') + }) + + it('exports binary output files as base64', async () => { + stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`) + if (provider === 'e2b') { + mockE2BCommandsRun.mockResolvedValue({ stdout: 'QkFTRTY0', stderr: '', exitCode: 0 }) + } else { + mockExecuteCommand.mockResolvedValue({ result: 'QkFTRTY0', exitCode: 0 }) + } + + const res = await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + outputSandboxPath: '/out/report.xlsx', + }) + + expect(res.exportedFileContent).toBe('QkFTRTY0') + expect(res.exportedFiles).toEqual({ '/out/report.xlsx': 'QkFTRTY0' }) + }) + + it('always kills the sandbox, even when execution throws', async () => { + if (provider === 'e2b') { + mockE2BRunCode.mockRejectedValue(new Error('kaboom')) + } else { + mockInterpreterRunCode.mockRejectedValue(new Error('kaboom')) + } + + await expect( + executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 }) + ).rejects.toThrow('kaboom') + + expect(provider === 'e2b' ? mockE2BKill : mockDelete).toHaveBeenCalledTimes(1) + }) +}) + +describe('provider selection', () => { + it('routes by SANDBOX_PROVIDER, defaulting to E2B when unset', async () => { + stubCodeRun('e2b', `${SIM_RESULT_PREFIX}null`) + stubCodeRun('daytona', `${SIM_RESULT_PREFIX}null`) + + mockEnv.SANDBOX_PROVIDER = undefined + await executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 }) + expect(mockE2BCreate).toHaveBeenCalledTimes(1) + expect(mockDaytonaCreate).not.toHaveBeenCalled() + + useProvider('daytona') + await executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 }) + expect(mockDaytonaCreate).toHaveBeenCalledTimes(1) + }) + + it('throws on an unknown SANDBOX_PROVIDER', async () => { + mockEnv.SANDBOX_PROVIDER = 'modal' + await expect( + executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 }) + ).rejects.toThrow(/Unknown SANDBOX_PROVIDER "modal"/) + }) + + it('resolves SANDBOX_PROVIDER case-insensitively', async () => { + mockEnv.SANDBOX_PROVIDER = 'Daytona' + stubCodeRun('daytona', `${SIM_RESULT_PREFIX}null`) + + await executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 }) + + expect(mockDaytonaCreate).toHaveBeenCalledTimes(1) + expect(mockE2BCreate).not.toHaveBeenCalled() + }) + + it('binds language at create time so JS never runs through the Python toolbox', async () => { + useProvider('daytona') + mockProcessCodeRun.mockResolvedValue({ result: `${SIM_RESULT_PREFIX}null`, exitCode: 0 }) + + await executeInSandbox({ code: 'x', language: CodeLanguage.JavaScript, timeoutMs: 1000 }) + + expect(mockDaytonaCreate).toHaveBeenCalledWith( + expect.objectContaining({ language: 'javascript' }) + ) + // JS must go through process.codeRun — CodeInterpreter is Python-only. + expect(mockProcessCodeRun).toHaveBeenCalledTimes(1) + expect(mockInterpreterRunCode).not.toHaveBeenCalled() + }) + + it('fails closed when a Daytona snapshot id is unset', async () => { + useProvider('daytona') + const original = mockEnv.DAYTONA_DOC_SNAPSHOT_ID + mockEnv.DAYTONA_DOC_SNAPSHOT_ID = undefined + + await expect( + executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + sandboxKind: 'doc', + }) + ).rejects.toThrow(/DAYTONA_DOC_SNAPSHOT_ID is unset/) + mockEnv.DAYTONA_DOC_SNAPSHOT_ID = original + }) + + it('accumulates streamed Pi output into stdout/stderr, not just callbacks', async () => { + useProvider('daytona') + // Daytona streams via getSessionCommandLogs callbacks; the runner must also + // return the joined output so the Pi cloud flow can parse markers from stdout + // and format errors from stderr. + mockGetSessionCommandLogs.mockImplementation( + async ( + _sid: string, + _cid: string, + onStdout: (c: string) => void, + onStderr: (c: string) => void + ) => { + onStdout('__BASE_SHA__=abc123\n') + onStderr('warning: detached HEAD\n') + } + ) + mockGetSessionCommand.mockResolvedValue({ exitCode: 2 }) + + const streamedOut: string[] = [] + const result = await withPiSandbox((runner) => + runner.run('git clone ...', { + timeoutMs: 1000, + onStdout: (c) => streamedOut.push(c), + }) + ) + + expect(result.stdout).toContain('__BASE_SHA__=abc123') + expect(result.stderr).toContain('detached HEAD') + expect(result.exitCode).toBe(2) + // Callbacks still fire for live streaming. + expect(streamedOut.join('')).toContain('__BASE_SHA__=abc123') + }) + + it('enforces the timeout on a hung streaming command', async () => { + useProvider('daytona') + // runAsync returns immediately, so a never-resolving log stream must be + // bounded by the timeout rather than awaited forever. + mockGetSessionCommandLogs.mockReturnValue(new Promise(() => {})) + + const result = await withPiSandbox((runner) => + runner.run('hang forever', { timeoutMs: 20, onStdout: () => {} }) + ) + + expect(result.exitCode).toBe(124) + expect(result.stderr).toContain('timed out') + // The session is deleted to terminate the still-running command. + expect(mockDeleteSession).toHaveBeenCalled() + }) + + it('surfaces the thrown error when the stream rejects', async () => { + useProvider('daytona') + // A failure with no chunks streamed must still carry the error detail, not an + // empty/opaque message. + mockGetSessionCommandLogs.mockRejectedValue(new Error('session died')) + + const result = await withPiSandbox((runner) => + runner.run('git clone ...', { timeoutMs: 1000, onStdout: () => {} }) + ) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('session died') + }) + + it('surfaces the error when starting the streamed command throws', async () => { + useProvider('daytona') + mockExecuteSessionCommand.mockRejectedValue(new Error('start failed')) + + const result = await withPiSandbox((runner) => + runner.run('git clone ...', { timeoutMs: 1000, onStdout: () => {} }) + ) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('start failed') + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/daytona.ts b/apps/sim/lib/execution/remote-sandbox/daytona.ts new file mode 100644 index 00000000000..97295b1b90e --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/daytona.ts @@ -0,0 +1,262 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { env } from '@/lib/core/config/env' +import { CodeLanguage } from '@/lib/execution/languages' +import type { + CreateSandboxOptions, + RunCommandOptions, + SandboxCodeResult, + SandboxCommandResult, + SandboxHandle, + SandboxKind, + SandboxProvider, +} from '@/lib/execution/remote-sandbox/types' + +const logger = createLogger('DaytonaSandboxProvider') + +/** Daytona expresses every timeout in seconds; the rest of Sim works in milliseconds. */ +function toSeconds(timeoutMs: number): number { + return Math.max(1, Math.ceil(timeoutMs / 1000)) +} + +function snapshotFor(kind: SandboxKind): string { + // Mirrors the E2B provider's fail-closed behaviour: never let LLM-authored code + // run in a provider default image just because a snapshot id is unset. + const snapshot = + kind === 'doc' + ? env.DAYTONA_DOC_SNAPSHOT_ID + : kind === 'pi' + ? env.DAYTONA_PI_SNAPSHOT_ID + : env.DAYTONA_SHELL_SNAPSHOT_ID + if (!snapshot) { + const varName = + kind === 'doc' + ? 'DAYTONA_DOC_SNAPSHOT_ID' + : kind === 'pi' + ? 'DAYTONA_PI_SNAPSHOT_ID' + : 'DAYTONA_SHELL_SNAPSHOT_ID' + throw new Error(`Daytona sandbox not configured (${varName} is unset)`) + } + return snapshot +} + +/** Daytona binds `codeRun`'s language to the sandbox, not the call. */ +function toDaytonaLanguage(language: CodeLanguage): string { + return language === CodeLanguage.Python ? 'python' : 'javascript' +} + +class DaytonaSandboxHandle implements SandboxHandle { + constructor( + private readonly sandbox: any, + private readonly language: CodeLanguage + ) {} + + get sandboxId(): string { + return this.sandbox.id + } + + async runCode(code: string, options: { timeoutMs: number }): Promise { + // Python goes through CodeInterpreter because it reports a structured + // `{ name, value, traceback }` error — the same shape E2B returns, which the + // route's line-offset error formatting depends on. CodeInterpreter is + // Python-only, so JS falls back to `process.codeRun`, whose language comes + // from the label bound at sandbox creation. + if (this.language === CodeLanguage.Python) { + const result = await this.sandbox.codeInterpreter.runCode(code, { + timeout: toSeconds(options.timeoutMs), + }) + return { + text: '', + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error + ? { + name: result.error.name, + value: result.error.value ?? result.error.message ?? '', + traceback: result.error.traceback, + } + : undefined, + } + } + + const result = await this.sandbox.process.codeRun(code, undefined, toSeconds(options.timeoutMs)) + const output: string = result.result ?? '' + if (result.exitCode !== 0) { + // `process.codeRun` has no structured error channel — the interpreter's + // stderr lands in `result`. Surface it as the traceback so the shape stays + // identical to the Python and E2B paths. + return { + text: '', + stdout: '', + stderr: output, + error: { name: 'Error', value: lastNonEmptyLine(output), traceback: output }, + } + } + return { text: '', stdout: output, stderr: '' } + } + + async runCommand(command: string, options: RunCommandOptions): Promise { + // `rootUser` needs no handling: Daytona already executes commands as uid 0. + if (options.onStdout || options.onStderr) { + return this.runStreamingCommand(command, options) + } + try { + const result = await this.sandbox.process.executeCommand( + command, + undefined, + options.envs, + toSeconds(options.timeoutMs) + ) + // Daytona merges the two streams into `result`; splitting them back out is + // not possible, so stdout carries everything and callers that join the two + // (every caller today) are unaffected. + return { stdout: result.result ?? '', stderr: '', exitCode: result.exitCode ?? 0 } + } catch (error) { + return { stdout: '', stderr: getErrorMessage(error), exitCode: 1 } + } + } + + /** + * Streaming path (Pi). `SessionExecuteRequest` carries no `env` field, so the + * environment is delivered as a file written through the filesystem API and + * sourced by the command. Secrets therefore never appear in a command line or + * the sandbox process list, and the file is removed before the command runs — + * preserving the per-command isolation the E2B path provides natively. + */ + private async runStreamingCommand( + command: string, + options: RunCommandOptions + ): Promise { + const sessionId = `sim-${generateShortId(12)}` + await this.sandbox.process.createSession(sessionId) + // Declared outside the try so the catch can return whatever streamed before a + // failure, rather than blanking the output. + let stdout = '' + let stderr = '' + try { + let script = command + if (options.envs && Object.keys(options.envs).length > 0) { + const envPath = `/tmp/.sim-env-${generateShortId(12)}` + const envFile = Object.entries(options.envs) + .map(([key, value]) => `${key}=${shellQuote(value)}`) + .join('\n') + await this.writeFile(envPath, envFile) + script = `set -a; . ${envPath}; set +a; rm -f ${envPath}; ${command}` + } + + const started = await this.sandbox.process.executeSessionCommand( + sessionId, + { command: script, runAsync: true }, + toSeconds(options.timeoutMs) + ) + const commandId: string = started.cmdId ?? started.commandId + // Accumulate the streamed chunks as well as forwarding them: callers read + // markers out of stdout (the Pi cloud flow parses __BASE_SHA__/__CHANGED__) + // and format failures from stderr, so returning empty strings here would + // both break marker extraction and blank out error messages even though + // the callbacks fired correctly. + // The `.catch` keeps its own reference to the stream's outcome AND ensures a + // late rejection is always handled — when the timeout wins the race, this + // promise is abandoned and deleteSession (finally) tears the session down, + // which rejects it; without the handler that would be an unhandledRejection. + let streamError: unknown + const streamed = this.sandbox.process + .getSessionCommandLogs( + sessionId, + commandId, + (chunk: string) => { + stdout += chunk + options.onStdout?.(chunk) + }, + (chunk: string) => { + stderr += chunk + options.onStderr?.(chunk) + } + ) + .then(() => 'done' as const) + .catch((error: unknown) => { + streamError = error + return 'error' as const + }) + + // `runAsync: true` returns immediately, so the timeout must be enforced here + // — otherwise a hung command streams forever, unlike E2B's commands.run + // which honors timeoutMs for the whole run. On timeout, the finally's + // deleteSession terminates the still-running command. + let timer: ReturnType | undefined + const timedOut = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), options.timeoutMs) + }) + let outcome: 'done' | 'error' | 'timeout' + try { + outcome = await Promise.race([streamed, timedOut]) + } finally { + if (timer) clearTimeout(timer) + } + if (outcome === 'timeout') { + return { + stdout, + stderr: stderr || `Command timed out after ${options.timeoutMs}ms`, + exitCode: 124, + } + } + if (outcome === 'error') { + return { stdout, stderr: stderr || getErrorMessage(streamError), exitCode: 1 } + } + + const finished = await this.sandbox.process.getSessionCommand(sessionId, commandId) + return { stdout, stderr, exitCode: finished.exitCode ?? 0 } + } catch (error) { + return { stdout, stderr: stderr || getErrorMessage(error), exitCode: 1 } + } finally { + try { + await this.sandbox.process.deleteSession(sessionId) + } catch {} + } + } + + async readFile(path: string): Promise { + const buffer = await this.sandbox.fs.downloadFile(path) + return buffer.toString('utf-8') + } + + async writeFile(path: string, content: string | ArrayBuffer): Promise { + const buffer = + typeof content === 'string' ? Buffer.from(content, 'utf-8') : Buffer.from(content) + await this.sandbox.fs.uploadFile(buffer, path) + } + + async kill(): Promise { + await this.sandbox.delete() + } +} + +/** Quotes a value for a POSIX `KEY=value` env file. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function lastNonEmptyLine(output: string): string { + const lines = output.split('\n').filter((line) => line.trim().length > 0) + return lines.length > 0 ? lines[lines.length - 1] : 'Execution failed' +} + +export const daytonaProvider: SandboxProvider = { + id: 'daytona', + async create(kind: SandboxKind, options?: CreateSandboxOptions): Promise { + const apiKey = env.DAYTONA_API_KEY + if (!apiKey) { + throw new Error('DAYTONA_API_KEY is required when the Daytona sandbox provider is selected') + } + const snapshot = snapshotFor(kind) + const language = options?.language ?? CodeLanguage.Python + logger.info('Creating Daytona sandbox', { kind, snapshot }) + + const { Daytona } = await import('@daytonaio/sdk') + const daytona = new Daytona({ apiKey }) + const sandbox = await daytona.create({ snapshot, language: toDaytonaLanguage(language) } as any) + + return new DaytonaSandboxHandle(sandbox, language) + }, +} diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts new file mode 100644 index 00000000000..f21a06e019d --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -0,0 +1,131 @@ +import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { CodeLanguage } from '@/lib/execution/languages' +import type { + CreateSandboxOptions, + RunCommandOptions, + SandboxCodeResult, + SandboxCommandResult, + SandboxHandle, + SandboxKind, + SandboxProvider, +} from '@/lib/execution/remote-sandbox/types' + +const logger = createLogger('E2BSandboxProvider') + +function templateFor(kind: SandboxKind): string | undefined { + // Document generation uses a dedicated template (python-pptx/docx/openpyxl/ + // reportlab + fonts); shell/code execution use the general shell template. + // Doc fails closed: never run LLM-authored Python in E2B's default template + // (which is not vetted for this) just because the doc template id is unset. + if (kind === 'doc') { + if (!env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) { + throw new Error('Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)') + } + return env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID + } + // Pi fails closed for the same reason: the coding agent needs the Pi CLI + git + // baked into a vetted template, never E2B's default image. + if (kind === 'pi') { + if (!env.E2B_PI_TEMPLATE_ID) { + throw new Error('Pi cloud agent not configured (E2B_PI_TEMPLATE_ID is unset)') + } + return env.E2B_PI_TEMPLATE_ID + } + return env.MOTHERSHIP_E2B_TEMPLATE_ID +} + +class E2BSandboxHandle implements SandboxHandle { + constructor( + private readonly sandbox: E2BSandbox, + private readonly language: CodeLanguage + ) {} + + get sandboxId(): string { + return this.sandbox.sandboxId + } + + async runCode(code: string, options: { timeoutMs: number }): Promise { + const execution = await this.sandbox.runCode(code, { + language: this.language === CodeLanguage.Python ? 'python' : 'javascript', + timeoutMs: options.timeoutMs, + }) + + // Kernel stream entries are chunks, not lines — each already carries its own + // newlines, and one long line can arrive split across several entries. + // Concatenate each stream verbatim: joining chunks with '\n' injected a + // newline at every chunk boundary, which corrupted large single-line + // __SIM_RESULT__ payloads and silently truncated the persisted result. + return { + text: execution.text ?? '', + stdout: (execution.logs?.stdout ?? []).join(''), + stderr: (execution.logs?.stderr ?? []).join(''), + error: execution.error + ? { + name: execution.error.name, + value: execution.error.value, + traceback: execution.error.traceback, + } + : undefined, + } + } + + async runCommand(command: string, options: RunCommandOptions): Promise { + try { + const result = await this.sandbox.commands.run(command, { + ...(options.envs ? { envs: options.envs } : {}), + timeoutMs: options.timeoutMs, + ...(options.rootUser ? { user: 'root' as const } : {}), + ...(options.onStdout ? { onStdout: options.onStdout } : {}), + ...(options.onStderr ? { onStderr: options.onStderr } : {}), + }) + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode } + } catch (error) { + // The SDK throws on non-zero exit; callers want the streams, not a throw. + const failure = error as { + stdout?: string + stderr?: string + message?: string + exitCode?: number + } + return { + stdout: failure.stdout ?? '', + stderr: failure.stderr ?? failure.message ?? getErrorMessage(error), + exitCode: failure.exitCode ?? 1, + } + } + } + + readFile(path: string): Promise { + return this.sandbox.files.read(path) + } + + async writeFile(path: string, content: string | ArrayBuffer): Promise { + await this.sandbox.files.write(path, content as string) + } + + async kill(): Promise { + await this.sandbox.kill() + } +} + +export const e2bProvider: SandboxProvider = { + id: 'e2b', + async create(kind: SandboxKind, options?: CreateSandboxOptions): Promise { + const apiKey = env.E2B_API_KEY + if (!apiKey) { + throw new Error('E2B_API_KEY is required when E2B is enabled') + } + const templateName = templateFor(kind) + logger.info('Creating E2B sandbox', { kind, template: templateName || '(default)' }) + + const { Sandbox } = await import('@e2b/code-interpreter') + const sandbox = templateName + ? await Sandbox.create(templateName, { apiKey }) + : await Sandbox.create({ apiKey }) + + return new E2BSandboxHandle(sandbox, options?.language ?? CodeLanguage.Python) + }, +} diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts new file mode 100644 index 00000000000..c9e3977eb03 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -0,0 +1,454 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import type { CodeLanguage } from '@/lib/execution/languages' +import { daytonaProvider } from '@/lib/execution/remote-sandbox/daytona' +import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b' +import type { + SandboxCommandResult, + SandboxExecutionRequest, + SandboxExecutionResult, + SandboxFile, + SandboxHandle, + SandboxKind, + SandboxProvider, + SandboxProviderId, + SandboxShellExecutionRequest, +} from '@/lib/execution/remote-sandbox/types' + +export type { + SandboxExecutionRequest, + SandboxExecutionResult, + SandboxFile, + SandboxShellExecutionRequest, +} from '@/lib/execution/remote-sandbox/types' + +const logger = createLogger('RemoteSandbox') + +/** + * The known sandbox providers. Keyed by {@link SandboxProviderId}, so adding an + * adapter is one entry here plus one member on the id union — the type makes an + * unhandled provider a compile error, not a runtime surprise. + */ +const PROVIDERS: Record = { + e2b: e2bProvider, + daytona: daytonaProvider, +} + +const DEFAULT_PROVIDER: SandboxProviderId = 'e2b' + +/** + * Resolves which provider serves this execution from the `SANDBOX_PROVIDER` env + * var (defaulting to {@link DEFAULT_PROVIDER}). + * + * Selection is deliberately resolved ONCE, before the sandbox is created, and is + * never revisited mid-execution: user code has side effects (HTTP calls, S3 + * writes, DB mutations), so retrying a partially-executed run on another provider + * could duplicate them. Changing providers is a config change — set + * `SANDBOX_PROVIDER` and redeploy; in-flight executions are unaffected. + */ +function resolveProvider(): SandboxProvider { + // Normalize casing identically to env-flags' availability gate — otherwise a + // value like `Daytona` would pass the gate (which lowercases) but miss this + // lowercase-keyed map and throw at create time. + const configured = env.SANDBOX_PROVIDER?.toLowerCase() + if (!configured) return PROVIDERS[DEFAULT_PROVIDER] + const provider = PROVIDERS[configured as SandboxProviderId] + if (!provider) { + throw new Error( + `Unknown SANDBOX_PROVIDER "${env.SANDBOX_PROVIDER}" (expected one of: ${Object.keys(PROVIDERS).join(', ')})` + ) + } + return provider +} + +async function createSandbox( + kind: SandboxKind, + options?: { language?: CodeLanguage } +): Promise { + const provider = resolveProvider() + const sandbox = await provider.create(kind, options) + logger.info('Created sandbox', { provider: provider.id, kind, sandboxId: sandbox.sandboxId }) + return sandbox +} + +/** + * Materializes sandbox input files before user code runs. `content` entries are written inline; + * `url` entries are fetched from inside the sandbox via `curl` — their bytes never pass through the + * web process, so the mount size is bounded by sandbox disk, not web heap. The URL and paths are + * passed as env vars (never interpolated into the shell) so a presigned query string can't break or + * inject. A failed fetch throws so user code never runs against a missing mount. + */ +async function writeSandboxInputs( + sandbox: SandboxHandle, + files: SandboxFile[] | undefined, + opts: { rootUser?: boolean } +): Promise { + if (!files?.length) return + const fetchedByUrl: string[] = [] + const writtenInline: string[] = [] + for (const file of files) { + if (file.type === 'url') { + const dir = file.path.slice(0, file.path.lastIndexOf('/')) + let result: SandboxCommandResult + try { + result = await sandbox.runCommand( + 'set -e; [ -n "$DIR" ] && mkdir -p "$DIR"; curl -fsS --retry 3 --retry-connrefused --max-time 300 "$URL" -o "$DST"', + { + envs: { URL: file.url, DST: file.path, DIR: dir }, + timeoutMs: 300_000, + rootUser: opts.rootUser, + } + ) + } catch (error) { + throw new Error( + `Failed to fetch mounted file into sandbox at ${file.path}: ${getErrorMessage(error)}` + ) + } + // Providers differ on whether a non-zero exit throws, so the exit code is + // checked explicitly — a silently-missing mount is exactly what this guard + // exists to prevent. + if (result.exitCode !== 0) { + // Daytona merges streams into stdout, so fall back to it for the real error. + throw new Error( + `Failed to fetch mounted file into sandbox at ${file.path}: ${result.stderr || result.stdout || `curl exited ${result.exitCode}`}` + ) + } + fetchedByUrl.push(file.path) + } else if (file.encoding === 'base64') { + const buf = Buffer.from(file.content, 'base64') + await sandbox.writeFile( + file.path, + buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + ) + writtenInline.push(file.path) + } else { + await sandbox.writeFile(file.path, file.content) + writtenInline.push(file.path) + } + } + // Split counts so it's visible whether a mount was fetched in-sandbox (by presigned URL, no bytes + // through the web process) or written inline. + logger.info('Materialized sandbox inputs', { + sandboxId: sandbox.sandboxId, + fetchedByUrlCount: fetchedByUrl.length, + writtenInlineCount: writtenInline.length, + fetchedByUrl, + writtenInline, + }) +} + +/** + * Marker prefix for the serialized code result printed to stdout. Emitters + * (the wrapper builders in the function-execute route) interpolate this + * constant so producer and parser cannot drift. + */ +export const SIM_RESULT_PREFIX = '__SIM_RESULT__=' + +/** + * Extracts the `__SIM_RESULT__=` marker line from stdout and parses its JSON + * payload. Takes the LAST marker line: the wrapper prints its marker after all + * user output, so an earlier user-printed line with the same prefix (debug + * output, a grepped log) never shadows the real result. `parseFailed` means + * the last marker's payload was not valid JSON — `rawPayload` carries it so + * callers whose markers are user-authored (shell) can fall back to the plain + * string, while wrapper-backed callers treat it as transport corruption. + */ +function extractSimResult(stdout: string): { + result: unknown + cleanedStdout: string + parseFailed: boolean + rawPayload?: string +} { + const lines = stdout.split('\n') + let markerIndex = -1 + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].startsWith(SIM_RESULT_PREFIX)) { + markerIndex = i + break + } + } + if (markerIndex === -1) { + return { result: null, cleanedStdout: stdout, parseFailed: false } + } + const rawPayload = lines[markerIndex].slice(SIM_RESULT_PREFIX.length) + let result: unknown = null + let parseFailed = false + try { + result = JSON.parse(rawPayload) + } catch { + parseFailed = true + } + const filteredLines = lines.filter((l) => !l.startsWith(SIM_RESULT_PREFIX)) + if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { + filteredLines.pop() + } + return { result, cleanedStdout: filteredLines.join('\n'), parseFailed, rawPayload } +} + +const SIM_RESULT_CORRUPTED_ERROR = + 'Sandbox result was corrupted in transport (the __SIM_RESULT__ line failed to parse). ' + + "Do not trust or persist this call's output. For large results, write the content to a " + + 'file inside the sandbox and export it via outputs.files[].sandboxPath instead of returning it.' + +function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { + const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() + const binaryExts = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.pdf', + '.zip', + '.mp3', + '.mp4', + '.docx', + '.pptx', + '.xlsx', + ]) + return binaryExts.has(ext) +} + +async function readSandboxOutputFile( + sandbox: SandboxHandle, + outputSandboxPath: string, + options?: { rootUser?: boolean } +): Promise { + try { + if (shouldReadSandboxPathAsBase64(outputSandboxPath)) { + const b64Result = await sandbox.runCommand(`base64 -w0 "${outputSandboxPath}"`, { + timeoutMs: 120_000, + rootUser: options?.rootUser, + }) + // Daytona merges streams into stdout, so fall back to it for the real error. + if (b64Result.exitCode !== 0) { + throw new Error(b64Result.stderr || b64Result.stdout || 'base64 failed') + } + return b64Result.stdout + } + return await sandbox.readFile(outputSandboxPath) + } catch (error) { + logger.warn('Failed to read requested sandbox output file', { + outputSandboxPath, + error: getErrorMessage(error), + }) + return undefined + } +} + +function requestedOutputSandboxPaths(req: { + outputSandboxPath?: string + outputSandboxPaths?: string[] +}): string[] { + const paths = [...(req.outputSandboxPaths ?? [])] + if (req.outputSandboxPath && !paths.includes(req.outputSandboxPath)) { + paths.push(req.outputSandboxPath) + } + return paths +} + +async function collectExportedFiles( + sandbox: SandboxHandle, + req: { outputSandboxPath?: string; outputSandboxPaths?: string[] }, + options?: { rootUser?: boolean } +): Promise<{ exportedFiles?: Record; exportedFileContent?: string }> { + const exportedFiles: Record = {} + for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { + const content = await readSandboxOutputFile(sandbox, outputSandboxPath, options) + if (content !== undefined) { + exportedFiles[outputSandboxPath] = content + } + } + return { + exportedFileContent: req.outputSandboxPath ? exportedFiles[req.outputSandboxPath] : undefined, + exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined, + } +} + +export async function executeInSandbox( + req: SandboxExecutionRequest +): Promise { + const { code, language, timeoutMs } = req + + const sandbox = await createSandbox(req.sandboxKind ?? 'code', { language }) + const sandboxId = sandbox.sandboxId + + try { + // Inside the try so a failed mount still kills the sandbox via the finally below. + await writeSandboxInputs(sandbox, req.sandboxFiles, {}) + + const execution = await sandbox.runCode(code, { timeoutMs }) + + if (execution.error) { + const errorMessage = `${execution.error.name}: ${execution.error.value}` + logger.error('Sandbox execution error', { sandboxId, error: execution.error, errorMessage }) + return { + result: null, + stdout: execution.error.traceback || errorMessage, + error: errorMessage, + sandboxId, + } + } + + // Distinct sources (final-expression text, stdout, stderr) join with '\n' so + // the marker is found no matter which stream carried it. Each individual + // stream is already concatenated verbatim by the provider, because injecting + // a newline at chunk boundaries corrupted large single-line payloads. + const combinedOutput = [execution.text, execution.stdout, execution.stderr] + .filter(Boolean) + .join('\n') + + const extraction = extractSimResult(combinedOutput) + const cleanedStdout = extraction.cleanedStdout + + // The wrapper always emits valid single-line JSON, so a marker that fails + // to parse means the payload was mangled in transport — never persist it. + if (extraction.parseFailed) { + logger.error('Sandbox result marker failed to parse', { + sandboxId, + stdoutLength: execution.stdout.length, + }) + return { + result: null, + stdout: cleanedStdout, + error: SIM_RESULT_CORRUPTED_ERROR, + sandboxId, + } + } + + const { exportedFiles, exportedFileContent } = await collectExportedFiles(sandbox, req) + + return { + result: extraction.result, + stdout: cleanedStdout, + sandboxId, + exportedFileContent, + exportedFiles, + } + } finally { + try { + await sandbox.kill() + } catch {} + } +} + +export async function executeShellInSandbox( + req: SandboxShellExecutionRequest +): Promise { + const { code, envs, timeoutMs } = req + + const sandbox = await createSandbox(req.sandboxKind ?? 'shell') + const sandboxId = sandbox.sandboxId + + try { + // Inside the try so a failed mount still kills the sandbox via the finally below. + await writeSandboxInputs(sandbox, req.sandboxFiles, { rootUser: true }) + + const result = await sandbox.runCommand(code, { + envs: { + ...envs, + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin', + }, + timeoutMs, + rootUser: true, + }) + + const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n') + + if (result.exitCode !== 0) { + // Daytona merges both streams into stdout (stderr is always empty), so fall + // back to stdout for the real command output before the generic message. + const errorMessage = + result.stderr || result.stdout || `Process exited with code ${result.exitCode}` + logger.error('Sandbox shell execution error', { + sandboxId, + exitCode: result.exitCode, + stderr: result.stderr?.slice(0, 500), + }) + return { result: null, stdout, error: errorMessage, sandboxId } + } + + // Shell scripts have no wrapper: any __SIM_RESULT__ line is user-authored + // (e.g. `echo "__SIM_RESULT__=$STATUS"`), so a non-JSON payload is a plain + // string result, not transport corruption. + const extraction = extractSimResult(stdout) + const parsed = extraction.parseFailed ? extraction.rawPayload : extraction.result + + const { exportedFiles, exportedFileContent } = await collectExportedFiles(sandbox, req, { + rootUser: true, + }) + + return { + result: parsed, + stdout: extraction.cleanedStdout, + sandboxId, + exportedFileContent, + exportedFiles, + } + } finally { + try { + await sandbox.kill() + } catch {} + } +} + +/** Result of one command run inside a Pi sandbox. */ +export interface PiSandboxCommandResult { + stdout: string + stderr: string + exitCode: number +} + +/** Runs commands and moves files inside a live Pi sandbox. */ +export interface PiSandboxRunner { + run( + command: string, + options: { + envs?: Record + timeoutMs: number + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void + } + ): Promise + readFile(path: string): Promise + /** + * Writes a file via the sandbox filesystem API. Bytes go through the provider + * SDK, never a shell, so untrusted content (the assembled prompt, a commit + * message) is delivered without any shell parsing — callers reference it by a + * fixed path. + */ + writeFile(path: string, content: string): Promise +} + +/** + * Creates a Pi sandbox, keeps it alive for the duration of `fn` (so the cloned + * repo persists across the clone -> agent -> push commands), streams command + * output, and always kills the sandbox afterward. Per-command envs are isolated, + * so secrets handed to one command never leak into the next. + */ +export async function withPiSandbox(fn: (runner: PiSandboxRunner) => Promise): Promise { + const sandbox = await createSandbox('pi') + logger.info('Started Pi sandbox', { sandboxId: sandbox.sandboxId }) + + const runner: PiSandboxRunner = { + run: (command, options) => + sandbox.runCommand(command, { + envs: options.envs, + timeoutMs: options.timeoutMs, + rootUser: true, + onStdout: options.onStdout, + onStderr: options.onStderr, + }), + readFile: (path) => sandbox.readFile(path), + writeFile: (path, content) => sandbox.writeFile(path, content), + } + + try { + return await fn(runner) + } finally { + try { + await sandbox.kill() + } catch {} + } +} diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts new file mode 100644 index 00000000000..5141f49b27d --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -0,0 +1,129 @@ +import type { CodeLanguage } from '@/lib/execution/languages' + +/** + * Which vetted image a sandbox runs in. Every kind fails closed when its + * template/snapshot id is unset, so LLM-authored code can never land in a + * provider's unvetted default image. + */ +export type SandboxKind = 'code' | 'shell' | 'doc' | 'pi' + +export type SandboxProviderId = 'e2b' | 'daytona' + +/** + * A sandbox input file. `content` entries are written inline; `url` entries are fetched from inside + * the sandbox (so large mounts never pass their bytes through the web process). + */ +export type SandboxFile = + | { type?: 'content'; path: string; content: string; encoding?: 'base64' } + | { type: 'url'; path: string; url: string } + +export interface SandboxExecutionRequest { + code: string + language: CodeLanguage + timeoutMs: number + sandboxFiles?: SandboxFile[] + outputSandboxPath?: string + outputSandboxPaths?: string[] + /** + * Which sandbox image to run in. Defaults to 'code' (mothership-shell). + * Document generation passes 'doc' so it runs in the doc image + * (mothership-docs) that has python-pptx/docx/openpyxl/reportlab installed. + */ + sandboxKind?: 'code' | 'doc' +} + +export interface SandboxShellExecutionRequest { + code: string + envs: Record + timeoutMs: number + sandboxFiles?: SandboxFile[] + outputSandboxPath?: string + outputSandboxPaths?: string[] + /** + * Which sandbox image to run in. Defaults to 'shell' (mothership-shell). + * The Node document engines (pptxgenjs/docx + react-icons/sharp) pass 'doc' so + * they run in the doc image (mothership-docs). + */ + sandboxKind?: 'shell' | 'doc' +} + +export interface SandboxExecutionResult { + result: unknown + stdout: string + sandboxId?: string + error?: string + exportedFileContent?: string + exportedFiles?: Record +} + +/** Result of one command run inside a sandbox. */ +export interface SandboxCommandResult { + stdout: string + stderr: string + exitCode: number +} + +/** + * Normalized error from a code execution. Both providers report this shape: + * E2B's `Execution.error` and Daytona's `ExecutionResult.error` agree on + * `{ name, value, traceback }`, so `formatSandboxError`'s line-offset handling + * works unchanged across providers. + */ +export interface SandboxCodeError { + name: string + value: string + traceback?: string +} + +/** Result of a code (non-shell) execution. */ +export interface SandboxCodeResult { + /** The final-expression value, when the provider surfaces one separately from stdout. */ + text: string + stdout: string + stderr: string + error?: SandboxCodeError +} + +export interface RunCommandOptions { + envs?: Record + timeoutMs: number + /** Run as root. The shell and Pi paths depend on this; the code path does not. */ + rootUser?: boolean + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void +} + +/** + * A live sandbox. Deliberately the smallest surface that satisfies every caller, + * so adding a third provider stays cheap. + */ +export interface SandboxHandle { + readonly sandboxId: string + /** + * Runs code in the language fixed at {@link SandboxProvider.create} time. + * Language is bound at creation rather than per call because Daytona applies it + * as a sandbox label (`code-toolbox-language`) and silently ignores a per-call + * override — passing `javascript` to its `codeRun` executes the source through + * Python instead. We create one sandbox per execution, so binding costs nothing. + */ + runCode(code: string, options: { timeoutMs: number }): Promise + runCommand(command: string, options: RunCommandOptions): Promise + readFile(path: string): Promise + /** + * Writes a file via the sandbox filesystem API. Bytes never pass through a + * shell, so untrusted content (an assembled prompt, a commit message) is + * delivered without any shell parsing. + */ + writeFile(path: string, content: string | ArrayBuffer): Promise + kill(): Promise +} + +export interface CreateSandboxOptions { + /** Bound at creation — see {@link SandboxHandle.runCode}. */ + language?: CodeLanguage +} + +export interface SandboxProvider { + readonly id: SandboxProviderId + create(kind: SandboxKind, options?: CreateSandboxOptions): Promise +} diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index 3b7e874847c..4e7cafbef42 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import type { ProviderTiming, TraceSpan } from '@/lib/logs/types' import { isConditionBlockType, @@ -17,6 +18,12 @@ const logger = createLogger('SpanFactory') /** A BlockLog that has already passed the id/type validity check. */ type ValidBlockLog = BlockLog & { blockType: string } +/** Converts arbitrary tool results to the object shape expected by trace spans. */ +function normalizeTraceOutput(value: unknown): Record | undefined { + if (value === undefined) return undefined + return isRecordLike(value) ? value : { value } +} + /** * Creates a TraceSpan from a BlockLog. Returns null for invalid logs. * @@ -190,6 +197,7 @@ function buildChildrenFromTimeSegments( const currentIndex = toolCallIndices.get(normalizedName) ?? 0 const match = callsForName[currentIndex] toolCallIndices.set(normalizedName, currentIndex + 1) + const output = normalizeTraceOutput(match?.result ?? match?.output) const toolChild: TraceSpan = { id: `${span.id}-segment-${index}`, @@ -200,9 +208,7 @@ function buildChildrenFromTimeSegments( endTime: segmentEndTime, status: match?.error || segment.errorMessage ? 'error' : 'success', input: match?.arguments ?? match?.input, - output: match?.error - ? { error: match.error, ...(match.result ?? match.output ?? {}) } - : (match?.result ?? match?.output), + output: match?.error ? { error: match.error, ...output } : output, } if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId if (segment.errorType) toolChild.errorType = segment.errorType @@ -269,6 +275,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS return toolCalls.map((tc, index) => { const startTime = tc.startTime ?? log.startedAt const endTime = tc.endTime ?? log.endedAt + const output = normalizeTraceOutput(tc.result ?? tc.output) return { id: `${span.id}-tool-${index}`, name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'), @@ -278,9 +285,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS endTime, status: tc.error ? 'error' : 'success', input: tc.arguments ?? tc.input, - output: tc.error - ? { error: tc.error, ...(tc.result ?? tc.output ?? {}) } - : (tc.result ?? tc.output), + output: tc.error ? { error: tc.error, ...output } : output, } }) } diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 2b2fff6e703..f763e08b995 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -196,6 +196,34 @@ describe('buildTraceSpans', () => { expect(secondToolCall.output).toEqual({ status: 200, data: 'response' }) }) + it.concurrent('normalizes scalar tool results only at the trace display boundary', () => { + const mockExecutionResult: ExecutionResult = { + success: true, + output: { content: 'Final output' }, + logs: [ + { + blockId: 'agent-scalar', + blockName: 'Scalar Agent', + blockType: 'agent', + startedAt: '2024-01-01T10:00:00.000Z', + endedAt: '2024-01-01T10:00:01.000Z', + durationMs: 1000, + success: true, + output: { + toolCalls: { + list: [{ name: 'boolean_tool', result: false }], + count: 1, + }, + }, + }, + ], + } + + const { traceSpans } = buildTraceSpans(mockExecutionResult) + + expect(traceSpans[0].children?.[0].output).toEqual({ value: false }) + }) + it.concurrent( 'extracts tool calls from agent block output with direct toolCalls array format', () => { diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts index c4fd6d47d1b..4f6f980bd39 100644 --- a/apps/sim/lib/monitoring/metrics.ts +++ b/apps/sim/lib/monitoring/metrics.ts @@ -41,7 +41,8 @@ const MAX_BUFFER = 10_000 // hard cap; drop oldest beyond this if flushing stall type ThrottleReason = 'billing_actor_limit' | 'upstream_retries_exhausted' type QueueReason = 'actor_requests' | 'dimension' | 'queue_position' -type FailureReason = 'rate_limited' | 'auth' | 'other' +/** `metering` marks a provider call that succeeded but could not be priced. */ +type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering' // Deployed envs (app + trigger worker) carry static AWS creds; local dev does // not. No creds → no-op, so recorders stay always-safe to call (same contract diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 6886590acbb..21fcc48c286 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -18,14 +18,14 @@ import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' -import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' const logger = createLogger('InboxExecutor') @@ -210,21 +210,16 @@ export async function executeInboxTask(taskId: string): Promise { return { attachments, ...downloaded } } - const [ - attachmentResult, - workspaceContext, - integrationTools, - userPermission, - billingAttribution, - entitlements, - ] = await Promise.all([ - fetchAttachments(), - generateWorkspaceContext(ws.id, userId), - buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), - resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), - computeWorkspaceEntitlements(ws.id, userId), - ]) + const workspaceAccess = await checkWorkspaceAccess(ws.id, userId) + const userPermission = workspaceAccess.permission + const [attachmentResult, workspaceContext, integrationTools, billingAttribution, entitlements] = + await Promise.all([ + fetchAttachments(), + generateWorkspaceContext(ws.id, userId, { workspaceAccess }), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), + computeWorkspaceEntitlements(ws.id, userId), + ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult const truncatedTask = { @@ -242,7 +237,7 @@ export async function executeInboxTask(taskId: string): Promise { messageId: userMessageId, isHosted, workspaceContext, - ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), + ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(userPermission ? { userPermission } : {}), ...(entitlements.length > 0 ? { entitlements } : {}), diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 61534aeb1a3..723717d1d36 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -213,6 +213,16 @@ export interface PostHogEventMap { source?: 'settings' | 'tool_input' } + skill_shared: { + skill_id: string + workspace_id: string + } + + skill_unshared: { + skill_id: string + workspace_id: string + } + workspace_deleted: { workspace_id: string workflow_count: number diff --git a/apps/sim/lib/skills/access.test.ts b/apps/sim/lib/skills/access.test.ts new file mode 100644 index 00000000000..d993edec97f --- /dev/null +++ b/apps/sim/lib/skills/access.test.ts @@ -0,0 +1,282 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkspaceAccess, mockGetUsersWithPermissions, dbState, makeChain, dbMock } = + vi.hoisted(() => { + const state = { results: [] as unknown[][] } + const chainFactory = () => { + const resolve = () => Promise.resolve(state.results.shift() ?? []) + const chain: any = {} + chain.from = vi.fn(() => chain) + chain.innerJoin = vi.fn(() => chain) + chain.where = vi.fn(() => chain) + chain.set = vi.fn(() => chain) + chain.limit = vi.fn(() => resolve()) + chain.returning = vi.fn(() => resolve()) + chain.then = (onFulfilled: any, onRejected: any) => resolve().then(onFulfilled, onRejected) + return chain + } + return { + mockCheckWorkspaceAccess: vi.fn(), + mockGetUsersWithPermissions: vi.fn(), + dbState: state, + makeChain: chainFactory, + dbMock: { + select: vi.fn(() => chainFactory()), + update: vi.fn(() => chainFactory()), + }, + } + }) + +vi.mock('@sim/db', () => ({ + db: dbMock, +})) + +vi.mock('@sim/db/schema', () => ({ + skill: { + id: 'skill.id', + workspaceId: 'skill.workspaceId', + name: 'skill.name', + }, + skillMember: { + id: 'skillMember.id', + skillId: 'skillMember.skillId', + userId: 'skillMember.userId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + inArray: vi.fn((a: unknown, b: unknown) => ({ inArray: [a, b] })), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, + getUsersWithPermissions: mockGetUsersWithPermissions, + resolveWorkspaceAccess: vi.fn(async (workspaceId: string, userId: string, provided?: any) => + provided && provided.workspace?.id === workspaceId + ? provided + : mockCheckWorkspaceAccess(workspaceId, userId) + ), +})) + +import { + checkSkillsUpdateAccess, + getEditableSkillIds, + getSkillActorContext, + listSkillEditors, + removeWorkspaceSkillMembershipsTx, +} from '@/lib/skills/access' + +const wsAdmin = { hasAccess: true, canWrite: true, canAdmin: true, workspace: { id: 'ws' } } +const wsWrite = { hasAccess: true, canWrite: true, canAdmin: false, workspace: { id: 'ws' } } +const wsRead = { hasAccess: true, canWrite: false, canAdmin: false, workspace: { id: 'ws' } } +const wsNone = { hasAccess: false, canWrite: false, canAdmin: false, workspace: null } + +beforeEach(() => { + vi.clearAllMocks() + dbState.results = [] + mockGetUsersWithPermissions.mockResolvedValue([]) +}) + +describe('getSkillActorContext', () => { + it('grants edit access from an explicit editor row', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], [{ id: 'row-1' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsRead) + + const ctx = await getSkillActorContext('s1', 'user1') + + expect(ctx.hasWorkspaceAccess).toBe(true) + expect(ctx.canEdit).toBe(true) + }) + + it('derives edit access from workspace admin without any rows', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(wsAdmin) + + const ctx = await getSkillActorContext('s1', 'admin-user') + + expect(ctx.canEdit).toBe(true) + }) + + it('lets rowless workspace members see but not edit', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(wsWrite) + + const ctx = await getSkillActorContext('s1', 'writer') + + expect(ctx.hasWorkspaceAccess).toBe(true) + expect(ctx.canEdit).toBe(false) + }) + + it('denies everything without workspace access, even with an editor row', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], [{ id: 'row-1' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsNone) + + const ctx = await getSkillActorContext('s1', 'outsider') + + expect(ctx.hasWorkspaceAccess).toBe(false) + expect(ctx.canEdit).toBe(false) + }) + + it('returns an empty context when the skill does not exist', async () => { + dbState.results = [[]] + + const ctx = await getSkillActorContext('missing', 'user1') + + expect(ctx.skill).toBeNull() + expect(ctx.hasWorkspaceAccess).toBe(false) + expect(ctx.canEdit).toBe(false) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) +}) + +describe('getEditableSkillIds', () => { + it('collects the editor rows from one workspace-scoped scan', async () => { + dbState.results = [[{ skillId: 's-mine' }, { skillId: 's-also-mine' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsRead) + + const access = await getEditableSkillIds('ws', 'user1') + + expect(access.canAdminWorkspace).toBe(false) + expect(access.editorSkillIds).toEqual(new Set(['s-mine', 's-also-mine'])) + }) + + it('flags workspace admins as editors of everything', async () => { + dbState.results = [[]] + mockCheckWorkspaceAccess.mockResolvedValue(wsAdmin) + + const access = await getEditableSkillIds('ws', 'admin-user') + + expect(access.canAdminWorkspace).toBe(true) + }) + + it('grants nothing without workspace access, even with editor rows', async () => { + dbState.results = [[{ skillId: 's-mine' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsNone) + + const access = await getEditableSkillIds('ws', 'outsider') + + expect(access.canAdminWorkspace).toBe(false) + expect(access.editorSkillIds.size).toBe(0) + }) +}) + +describe('listSkillEditors', () => { + const roster = (users: Array<{ userId: string; permissionType: string }>) => + users.map((u) => ({ + userId: u.userId, + permissionType: u.permissionType, + name: `${u.userId}-name`, + email: `${u.userId}@x.com`, + image: null, + })) + + it('lists derived workspace admins plus explicit editors still in the roster', async () => { + dbState.results = [ + [ + { id: 'row-1', userId: 'writer' }, + { id: 'row-2', userId: 'ghost' }, + ], + ] + mockGetUsersWithPermissions.mockResolvedValue( + roster([ + { userId: 'boss', permissionType: 'admin' }, + { userId: 'writer', permissionType: 'write' }, + { userId: 'reader', permissionType: 'read' }, + ]) + ) + + const editors = await listSkillEditors({ id: 's1', workspaceId: 'ws' }) + const byUser = new Map(editors.map((e) => [e.userId, e])) + + expect(byUser.get('boss')).toMatchObject({ + id: 'workspace-admin-boss', + isWorkspaceAdmin: true, + userEmail: 'boss@x.com', + }) + expect(byUser.get('writer')).toMatchObject({ id: 'row-1', isWorkspaceAdmin: false }) + // Workspace members without a row are not editors. + expect(byUser.has('reader')).toBe(false) + // Rows for users no longer in the workspace never render. + expect(byUser.has('ghost')).toBe(false) + }) + + it('keeps a workspace admin flagged as derived even when they hold an explicit row', async () => { + dbState.results = [[{ id: 'row-1', userId: 'boss' }]] + mockGetUsersWithPermissions.mockResolvedValue( + roster([{ userId: 'boss', permissionType: 'admin' }]) + ) + + const editors = await listSkillEditors({ id: 's1', workspaceId: 'ws' }) + + expect(editors).toHaveLength(1) + expect(editors[0]).toMatchObject({ id: 'row-1', userId: 'boss', isWorkspaceAdmin: true }) + }) +}) + +describe('checkSkillsUpdateAccess', () => { + it('returns nothing for an empty id list without querying', async () => { + const result = await checkSkillsUpdateAccess({ workspaceId: 'ws', userId: 'u', skillIds: [] }) + expect(result.existingIds.size).toBe(0) + expect(result.denied).toEqual([]) + expect(dbMock.select).not.toHaveBeenCalled() + }) + + it('partitions resolvable ids and denies skills without an editor row', async () => { + dbState.results = [ + [ + { id: 's-mine', name: 'mine' }, + { id: 's-other', name: 'other' }, + ], + [{ skillId: 's-mine' }], + ] + mockCheckWorkspaceAccess.mockResolvedValue(wsWrite) + + const result = await checkSkillsUpdateAccess({ + workspaceId: 'ws', + userId: 'u', + skillIds: ['s-mine', 's-other', 's-create'], + }) + + expect(result.existingIds).toEqual(new Set(['s-mine', 's-other'])) + expect(result.denied).toEqual([{ id: 's-other', name: 'other' }]) + }) + + it('denies nothing for workspace admins', async () => { + dbState.results = [[{ id: 's-any', name: 'any' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(wsAdmin) + + const result = await checkSkillsUpdateAccess({ + workspaceId: 'ws', + userId: 'admin-user', + skillIds: ['s-any'], + }) + + expect(result.denied).toEqual([]) + }) +}) + +describe('removeWorkspaceSkillMembershipsTx', () => { + it('returns 0 for an empty workspace list without querying', async () => { + const tx = { select: vi.fn(() => makeChain()), delete: vi.fn(() => makeChain()) } + expect(await removeWorkspaceSkillMembershipsTx(tx as never, [], 'u')).toBe(0) + expect(tx.delete).not.toHaveBeenCalled() + }) + + it('deletes every editor grant for the user in the workspaces and counts them', async () => { + const { eq } = await import('drizzle-orm') + dbState.results = [[{ id: 'm1' }, { id: 'm2' }]] + const tx = { select: vi.fn(() => makeChain()), delete: vi.fn(() => makeChain()) } + + expect(await removeWorkspaceSkillMembershipsTx(tx as never, ['ws'], 'u')).toBe(2) + expect(tx.delete).toHaveBeenCalledTimes(1) + // Rows are plain editor grants — the delete has no status filter, so a + // re-invited user starts with no edit rights until re-added. + expect(vi.mocked(eq).mock.calls).toContainEqual(['skillMember.userId', 'u']) + expect(vi.mocked(eq).mock.calls.some(([field]) => field === 'skillMember.status')).toBe(false) + }) +}) diff --git a/apps/sim/lib/skills/access.ts b/apps/sim/lib/skills/access.ts new file mode 100644 index 00000000000..ca835a47414 --- /dev/null +++ b/apps/sim/lib/skills/access.ts @@ -0,0 +1,212 @@ +import { db } from '@sim/db' +import { skill, skillMember } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { + getUsersWithPermissions, + resolveWorkspaceAccess, + type WorkspaceAccess, +} from '@/lib/workspaces/permissions/utils' + +type SkillRecord = typeof skill.$inferSelect + +export interface SkillActorContext { + skill: SkillRecord | null + /** Whether the actor can see and use the skill — plain workspace access. */ + hasWorkspaceAccess: boolean + /** + * Whether the actor can edit, delete, and share the skill: an explicit + * `skill_member` editor row, or derived workspace admin (always, undemotable). + */ + canEdit: boolean +} + +/** + * Resolves the acting user's context for a single skill. Everyone with + * workspace access sees and uses every skill; editing is gated by the editors + * list. Builtin skills are code-only and have no editors; callers guard with + * `isBuiltinSkillId` before reaching this. + */ +export async function getSkillActorContext( + skillId: string, + userId: string +): Promise { + const [skillRow] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) + + if (!skillRow?.workspaceId) { + return { skill: skillRow ?? null, hasWorkspaceAccess: false, canEdit: false } + } + + const [workspaceAccess, [editorRow]] = await Promise.all([ + resolveWorkspaceAccess(skillRow.workspaceId, userId), + db + .select({ id: skillMember.id }) + .from(skillMember) + .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId))) + .limit(1), + ]) + + return { + skill: skillRow, + hasWorkspaceAccess: workspaceAccess.hasAccess, + canEdit: workspaceAccess.hasAccess && (workspaceAccess.canAdmin || !!editorRow), + } +} + +export interface EditableSkillIds { + /** Workspace admins are derived editors of every skill in the workspace. */ + canAdminWorkspace: boolean + /** Skills where the user holds an explicit editor row. */ + editorSkillIds: Set +} + +/** + * Batch edit-access surface for tagging many skills at once (list routes, + * upsert authorization): one workspace-access lookup plus one editor-row scan + * scoped to the workspace. A skill is editable when `canAdminWorkspace` or its + * id is in `editorSkillIds`. + * + * Pass `workspaceAccess` when the caller already resolved it to skip a + * redundant lookup. + */ +export async function getEditableSkillIds( + workspaceId: string, + userId: string, + options?: { workspaceAccess?: WorkspaceAccess } +): Promise { + const [workspaceAccess, editorRows] = await Promise.all([ + resolveWorkspaceAccess(workspaceId, userId, options?.workspaceAccess), + db + .select({ skillId: skillMember.skillId }) + .from(skillMember) + .innerJoin(skill, eq(skillMember.skillId, skill.id)) + .where(and(eq(skill.workspaceId, workspaceId), eq(skillMember.userId, userId))), + ]) + + if (!workspaceAccess.hasAccess) { + return { canAdminWorkspace: false, editorSkillIds: new Set() } + } + + return { + canAdminWorkspace: workspaceAccess.canAdmin, + editorSkillIds: new Set(editorRows.map((row) => row.skillId)), + } +} + +export interface SkillEditor { + /** Explicit row id, or a synthetic `workspace-admin-` id for derived admins without rows. */ + id: string + userId: string + userName: string | null + userEmail: string | null + userImage: string | null + /** Derived editors — always present, cannot be removed from the list. */ + isWorkspaceAdmin: boolean +} + +/** + * The editor roster for a skill: every workspace admin (derived, undemotable) + * plus every explicit-row user still in the workspace roster. Rows for users + * who left the workspace are ignored, exactly as edit enforcement ignores them. + */ +export async function listSkillEditors(skillRow: { + id: string + workspaceId: string +}): Promise { + const [explicitRows, workspaceMembers] = await Promise.all([ + db + .select({ id: skillMember.id, userId: skillMember.userId }) + .from(skillMember) + .where(eq(skillMember.skillId, skillRow.id)), + getUsersWithPermissions(skillRow.workspaceId), + ]) + + const rowByUser = new Map(explicitRows.map((row) => [row.userId, row])) + + const editors: SkillEditor[] = [] + for (const wsMember of workspaceMembers) { + const row = rowByUser.get(wsMember.userId) + const isWorkspaceAdmin = wsMember.permissionType === 'admin' + if (!row && !isWorkspaceAdmin) continue + + editors.push({ + id: row?.id ?? `workspace-admin-${wsMember.userId}`, + userId: wsMember.userId, + userName: wsMember.name, + userEmail: wsMember.email, + userImage: wsMember.image ?? null, + isWorkspaceAdmin, + }) + } + return editors +} + +export interface SkillsUpdateAccess { + /** Ids from the request that resolve to existing skills in the workspace. */ + existingIds: Set + /** Existing skills the user may not update (not an editor, not a workspace admin). */ + denied: Array<{ id: string; name: string }> +} + +/** + * Partitions an upsert request's skill ids for authorization: ids that resolve + * to existing workspace skills require skill editor access; unresolved ids are + * creates, gated by workspace write permission instead. + */ +export async function checkSkillsUpdateAccess(params: { + workspaceId: string + userId: string + skillIds: string[] + workspaceAccess?: WorkspaceAccess +}): Promise { + if (params.skillIds.length === 0) return { existingIds: new Set(), denied: [] } + + const rows = await db + .select({ id: skill.id, name: skill.name }) + .from(skill) + .where(and(eq(skill.workspaceId, params.workspaceId), inArray(skill.id, params.skillIds))) + + const existingIds = new Set(rows.map((row) => row.id)) + if (rows.length === 0) return { existingIds, denied: [] } + + const access = await getEditableSkillIds(params.workspaceId, params.userId, { + workspaceAccess: params.workspaceAccess, + }) + const denied = access.canAdminWorkspace + ? [] + : rows.filter((row) => !access.editorSkillIds.has(row.id)) + + return { existingIds, denied } +} + +/** + * Removes a user's skill editor grants across one or more workspaces when they + * leave (workspace removal, org removal/transfer). Rows are editor grants + * only — everyone in the workspace already sees and uses every skill — so a + * later re-invite lands them with no edit rights until re-added. Workspace + * admins are derived editors, so no promotion is needed to avoid orphaning a + * skill. Returns the number of grants removed. + */ +export async function removeWorkspaceSkillMembershipsTx( + tx: DbOrTx, + workspaceId: string | string[], + userId: string +): Promise { + const workspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId] + if (workspaceIds.length === 0) return 0 + + const removed = await tx + .delete(skillMember) + .where( + and( + eq(skillMember.userId, userId), + inArray( + skillMember.skillId, + tx.select({ id: skill.id }).from(skill).where(inArray(skill.workspaceId, workspaceIds)) + ) + ) + ) + .returning({ id: skillMember.id }) + + return removed.length +} diff --git a/apps/sim/lib/webhooks/providers/whatsapp.test.ts b/apps/sim/lib/webhooks/providers/whatsapp.test.ts index e356365c051..c26763dea02 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.test.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.test.ts @@ -192,4 +192,88 @@ describe('WhatsApp webhook provider', () => { }, ]) }) + + async function formatMediaMessage(message: Record) { + const result = await whatsappHandler.formatInput!({ + webhook: { id: 'wh_media', providerConfig: {} }, + workflow: { id: 'wf_media', userId: 'user_media' }, + body: { + object: 'whatsapp_business_account', + entry: [ + { + changes: [ + { + field: 'messages', + value: { metadata: { phone_number_id: '12345' }, messages: [message] }, + }, + ], + }, + ], + }, + headers: {}, + requestId: 'wa-format-media', + }) + + return result.input as Record + } + + it('surfaces the media asset ID from an incoming image message', async () => { + const input = await formatMediaMessage({ + id: 'wamid.image.1', + from: '15550101', + timestamp: '1700000000', + type: 'image', + image: { + caption: 'Taj Mahal', + mime_type: 'image/jpeg', + sha256: 'abc123', + id: '1003383421387256', + }, + }) + + // The media asset ID is a different value from the wamid message ID. + expect(input.messageId).toBe('wamid.image.1') + expect(input.mediaId).toBe('1003383421387256') + expect(input.mediaMimeType).toBe('image/jpeg') + expect(input.caption).toBe('Taj Mahal') + }) + + it('surfaces the filename on an incoming document message', async () => { + const input = await formatMediaMessage({ + id: 'wamid.doc.1', + from: '15550101', + timestamp: '1700000000', + type: 'document', + document: { filename: 'receipt.pdf', mime_type: 'application/pdf', id: '999' }, + }) + + expect(input.mediaId).toBe('999') + expect((input.messages as Array>)[0].mediaFilename).toBe('receipt.pdf') + }) + + it('leaves media fields unset for a text message', async () => { + const input = await formatMediaMessage({ + id: 'wamid.text.1', + from: '15550101', + timestamp: '1700000000', + type: 'text', + text: { body: 'hello' }, + }) + + expect(input.mediaId).toBeUndefined() + expect(input.mediaMimeType).toBeUndefined() + expect(input.caption).toBeUndefined() + }) + + it('ignores a media type whose payload object is missing', async () => { + const input = await formatMediaMessage({ + id: 'wamid.image.2', + from: '15550101', + timestamp: '1700000000', + type: 'image', + }) + + expect(input.messageId).toBe('wamid.image.2') + expect(input.mediaId).toBeUndefined() + }) }) diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 46df92e3996..5bde7ef5de7 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -60,11 +60,41 @@ function normalizeWhatsAppContact(contact: Record) { } } +/** Message types whose payload carries a downloadable media asset. */ +const WHATSAPP_MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker']) + +/** + * Pull the media asset off a typed media message. WhatsApp nests it under a key + * matching the message `type` — `{ type: 'image', image: { id, mime_type, ... } }`. + * Note `media.id` is the media asset ID passed to Download Media, which is a + * different value from `message.id` (the `wamid.` message identifier). + */ +function extractWhatsAppMedia(message: Record) { + const type = typeof message.type === 'string' ? message.type : undefined + if (!type || !WHATSAPP_MEDIA_TYPES.has(type)) { + return undefined + } + + const media = isRecord(message[type]) ? (message[type] as Record) : undefined + if (!media) { + return undefined + } + + return { + mediaId: typeof media.id === 'string' ? media.id : undefined, + mediaMimeType: typeof media.mime_type === 'string' ? media.mime_type : undefined, + mediaSha256: typeof media.sha256 === 'string' ? media.sha256 : undefined, + mediaFilename: typeof media.filename === 'string' ? media.filename : undefined, + caption: typeof media.caption === 'string' ? media.caption : undefined, + } +} + function normalizeWhatsAppMessage( message: Record, metadata?: Record ) { const text = isRecord(message.text) ? message.text : undefined + const media = extractWhatsAppMedia(message) return { messageId: typeof message.id === 'string' ? message.id : undefined, @@ -78,6 +108,11 @@ function normalizeWhatsAppMessage( text: typeof text?.body === 'string' ? text.body : undefined, timestamp: typeof message.timestamp === 'string' ? message.timestamp : undefined, messageType: typeof message.type === 'string' ? message.type : undefined, + mediaId: media?.mediaId, + mediaMimeType: media?.mediaMimeType, + mediaSha256: media?.mediaSha256, + mediaFilename: media?.mediaFilename, + caption: media?.caption, raw: message, } } @@ -284,6 +319,11 @@ export const whatsappHandler: WebhookProviderHandler = { text?: string timestamp?: string messageType?: string + mediaId?: string + mediaMimeType?: string + mediaSha256?: string + mediaFilename?: string + caption?: string raw: Record }> = [] const statuses: Array<{ @@ -355,6 +395,9 @@ export const whatsappHandler: WebhookProviderHandler = { text: firstMessage?.text, timestamp: firstMessage?.timestamp ?? firstStatus?.timestamp, messageType: firstMessage?.messageType, + mediaId: firstMessage?.mediaId, + mediaMimeType: firstMessage?.mediaMimeType, + caption: firstMessage?.caption, status: firstStatus?.status, contact: contacts[0], webhookContacts: contacts, diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 43408586eed..639ef051945 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -54,6 +54,15 @@ export interface ExecuteWorkflowOptions { executionMode?: 'sync' | 'stream' | 'async' /** Immutable actor/payer decision captured by preprocessing. */ billingAttribution?: BillingAttributionSnapshot + /** Deployed-chat thinking policy; persisted on the snapshot for resume. */ + includeThinking?: boolean + /** Deployed-chat tool lifecycle policy; persisted on the snapshot for resume. */ + includeToolCalls?: boolean + /** + * Run-level agent-events opt-in (see {@link ExecutionMetadata.agentEvents}). + * Callers set this only when the surface consumes thinking/tool events. + */ + agentEvents?: boolean } export interface WorkflowInfo { @@ -111,6 +120,12 @@ export async function executeWorkflow( largeValueKeys: streamConfig?.largeValueKeys, fileKeys: streamConfig?.fileKeys, executionMode: streamConfig?.executionMode, + includeThinking: streamConfig?.includeThinking === true ? true : undefined, + includeToolCalls: + typeof streamConfig?.includeToolCalls === 'boolean' + ? streamConfig.includeToolCalls + : undefined, + agentEvents: streamConfig?.agentEvents === true ? true : undefined, } const snapshot = new ExecutionSnapshot( diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index d7212e8c1ed..5b94f977613 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -17,7 +17,26 @@ export type ExecutionEventType = | 'block:error' | 'block:childWorkflowStarted' | 'stream:chunk' + /** Live-only: clears a block's streamed answer text (intermediate turn). */ + | 'stream:chunk_reset' | 'stream:done' + /** Live-only agent thinking delta (not buffered for reconnect replay). */ + | 'stream:thinking' + /** Live-only tool lifecycle (not buffered for reconnect replay). */ + | 'stream:tool' + +/** + * Event types that are live-only: forwarded to connected clients but excluded + * from reconnect replay buffers (same rule as answer chunks — guaranteed `seq` + * replay for stream events is out of scope). + */ +export const LIVE_ONLY_EXECUTION_EVENT_TYPES: ReadonlySet = new Set([ + 'stream:chunk', + 'stream:chunk_reset', + 'stream:done', + 'stream:thinking', + 'stream:tool', +]) /** * Base event structure for SSE @@ -208,6 +227,20 @@ interface StreamChunkEvent extends BaseExecutionEvent { } } +/** + * Live-only reconciliation for agent-events runs: the answer text streamed so + * far for `blockId` belonged to an intermediate turn (tool calls follow). + * Clients discard the block's accumulated streamed text; the final turn's + * text re-streams as regular `stream:chunk` events after tools settle. + */ +interface StreamChunkResetEvent extends BaseExecutionEvent { + type: 'stream:chunk_reset' + workflowId: string + data: { + blockId: string + } +} + /** * Stream done event */ @@ -219,6 +252,36 @@ interface StreamDoneEvent extends BaseExecutionEvent { } } +/** + * Live thinking delta from an agent-events provider sink (canvas / draft runs). + * Builder runs show provider-exposed signals when the sink is attached + * (executor already disables the sink under PII redaction). + */ +interface StreamThinkingEvent extends BaseExecutionEvent { + type: 'stream:thinking' + workflowId: string + data: { + blockId: string + text: string + } +} + +/** + * Live tool lifecycle from an agent-events provider sink. + * Name + status only — never args or results. + */ +interface StreamToolEvent extends BaseExecutionEvent { + type: 'stream:tool' + workflowId: string + data: { + blockId: string + phase: 'start' | 'end' + id: string + name: string + status?: 'success' | 'error' | 'cancelled' + } +} + /** * Union type of all execution events */ @@ -233,7 +296,10 @@ export type ExecutionEvent = | BlockErrorEvent | BlockChildWorkflowStartedEvent | StreamChunkEvent + | StreamChunkResetEvent | StreamDoneEvent + | StreamThinkingEvent + | StreamToolEvent export type ExecutionStartedData = ExecutionStartedEvent['data'] export type ExecutionCompletedData = ExecutionCompletedEvent['data'] @@ -245,7 +311,10 @@ export type BlockCompletedData = BlockCompletedEvent['data'] export type BlockErrorData = BlockErrorEvent['data'] export type BlockChildWorkflowStartedData = BlockChildWorkflowStartedEvent['data'] export type StreamChunkData = StreamChunkEvent['data'] +export type StreamChunkResetData = StreamChunkResetEvent['data'] export type StreamDoneData = StreamDoneEvent['data'] +export type StreamThinkingData = StreamThinkingEvent['data'] +export type StreamToolData = StreamToolEvent['data'] /** * Helper to create SSE formatted message diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 3be39c26d7f..69adefd2174 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -25,7 +25,10 @@ import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' -import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' +import { + type ExecutionEvent, + LIVE_ONLY_EXECUTION_EVENT_TYPES, +} from '@/lib/workflows/executor/execution-events' import { createPausedExecutionResumeMetadata, parsePausedExecutionResumeMetadata, @@ -35,6 +38,10 @@ import { normalizeAutomaticResumeWaitingReason, resolveAutomaticResumeAdmissionFailure, } from '@/lib/workflows/executor/resume-policy' +import { + forwardAgentStreamToExecutionEvents, + shouldForwardAnswerTextFromSink, +} from '@/lib/workflows/streaming/forward-agent-stream-events' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ChildWorkflowContext, @@ -1276,7 +1283,7 @@ export class PauseResumeManager { event: ExecutionEvent, terminalStatus?: TerminalExecutionStreamStatus ) => { - const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done' + const isBuffered = !LIVE_ONLY_EXECUTION_EVENT_TYPES.has(event.type) if (isBuffered) { const entry = terminalStatus ? await eventWriter.writeTerminal(event, terminalStatus).catch((error) => { @@ -1418,12 +1425,26 @@ export class PauseResumeManager { ? streamingExec.execution.blockId : undefined const blockId = typeof blockIdValue === 'string' ? blockIdValue : '' + + // Live answer text rides the sink when available; the byte stream is + // then drained without re-emitting chunks (same final-turn content). + const answerTextFromSink = shouldForwardAnswerTextFromSink(streamingExec) + + const unsubscribe = forwardAgentStreamToExecutionEvents(streamingExec, { + blockId, + executionId: resumeExecutionId, + workflowId, + sendEvent: writeBufferedEvent, + forwardAnswerText: answerTextFromSink, + }) + const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() try { while (true) { const { done, value } = await reader.read() if (done) break + if (answerTextFromSink) continue const chunk = decoder.decode(value, { stream: true }) await writeBufferedEvent({ type: 'stream:chunk', @@ -1447,6 +1468,7 @@ export class PauseResumeManager { error: toError(streamError).message, }) } finally { + unsubscribe() try { await reader.cancel().catch(() => {}) } catch {} diff --git a/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts new file mode 100644 index 00000000000..87056804a6b --- /dev/null +++ b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ +import { afterAll, describe, expect, it, vi } from 'vitest' +import type { BlockState } from '@/stores/workflows/workflow/types' + +vi.unmock('@/blocks/registry') + +import * as blocksBarrel from '@/blocks' +import { getBlock as getRealBlock } from '@/blocks/registry' +import { backfillWhatsAppInteractiveType } from './whatsapp-interactive-type' + +/** + * Under `isolate: false` the module under test may already be cached from an + * earlier test file, bound to the global `@/blocks/registry` mock through the + * `@/blocks` barrel. `vi.unmock` alone cannot rebind that cached instance, so + * route the barrel's `getBlock` to the real registry via a spy on the shared + * barrel namespace — it patches whichever instance the cached module reads. + */ +const getBlockSpy = vi.spyOn(blocksBarrel, 'getBlock').mockImplementation(getRealBlock) + +afterAll(() => { + getBlockSpy.mockRestore() +}) + +const BUTTONS = '[{"type":"reply","reply":{"id":"yes","title":"Yes"}}]' +const SECTIONS = '[{"title":"Menu","rows":[{"id":"r1","title":"Row 1"}]}]' + +function whatsappBlock(values: Record): BlockState { + return { + id: 'block-1', + type: 'whatsapp', + name: 'WhatsApp', + position: { x: 0, y: 0 }, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ), + outputs: {}, + enabled: true, + } as BlockState +} + +function interactiveTypeOf(block: BlockState): unknown { + return block.subBlocks.interactiveType?.value +} + +describe('backfillWhatsAppInteractiveType', () => { + it('resolves a legacy list block to list so its sections survive serialization', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + bodyText: 'Pick one', + listButtonText: 'Menu', + sections: SECTIONS, + buttons: '', + }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('list') + }) + + it('resolves a legacy reply-buttons block to button', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + bodyText: 'Pick one', + buttons: BUTTONS, + sections: '', + }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('writes a well-formed subblock entry using the configured control type', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_interactive', buttons: BUTTONS }), + }) + + expect(blocks.b1.subBlocks.interactiveType).toEqual({ + id: 'interactiveType', + type: 'dropdown', + value: 'button', + }) + }) + + it('treats an unresolved block reference in sections as a configured list', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + sections: '', + buttons: '', + }), + }) + + expect(interactiveTypeOf(blocks.b1)).toBe('list') + }) + + it('treats an emptied array literal as unconfigured', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + sections: '[]', + buttons: BUTTONS, + }), + }) + + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('falls back to button when neither variant was configured', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_interactive', bodyText: 'Pick one' }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('prefers button when both variants somehow hold a value, matching the tool', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + buttons: BUTTONS, + sections: SECTIONS, + }), + }) + + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('is idempotent — a block that already carries a value is untouched', () => { + const input = { + b1: whatsappBlock({ + operation: 'send_interactive', + interactiveType: 'list', + sections: SECTIONS, + }), + } + + const { blocks, migrated } = backfillWhatsAppInteractiveType(input) + + expect(migrated).toBe(false) + expect(blocks.b1).toBe(input.b1) + }) + + it('skips WhatsApp blocks on other operations', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_message', message: 'hi' }), + }) + + expect(migrated).toBe(false) + expect(interactiveTypeOf(blocks.b1)).toBeUndefined() + }) + + it('skips blocks of other types', () => { + const input: Record = { + b1: { + id: 'b1', + type: 'function', + name: 'Function', + position: { x: 0, y: 0 }, + subBlocks: { code: { id: 'code', type: 'code', value: 'return 1' } }, + outputs: {}, + enabled: true, + } as BlockState, + } + + const { blocks, migrated } = backfillWhatsAppInteractiveType(input) + + expect(migrated).toBe(false) + expect(blocks.b1).toBe(input.b1) + }) + + it('does not mutate the input blocks', () => { + const input = { + b1: whatsappBlock({ operation: 'send_interactive', sections: SECTIONS }), + } + + const { blocks } = backfillWhatsAppInteractiveType(input) + + expect(interactiveTypeOf(input.b1)).toBeUndefined() + expect(interactiveTypeOf(blocks.b1)).toBe('list') + expect(blocks).not.toBe(input) + }) + + it('migrates every affected block in one pass', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_interactive', sections: SECTIONS }), + b2: whatsappBlock({ operation: 'send_interactive', buttons: BUTTONS }), + b3: whatsappBlock({ operation: 'send_message', message: 'hi' }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('list') + expect(interactiveTypeOf(blocks.b2)).toBe('button') + expect(interactiveTypeOf(blocks.b3)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.ts b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.ts new file mode 100644 index 00000000000..cce163bd6f6 --- /dev/null +++ b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import { getBlock } from '@/blocks' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('WhatsAppInteractiveTypeMigration') + +const WHATSAPP_BLOCK_TYPE = 'whatsapp' +const INTERACTIVE_TYPE_ID = 'interactiveType' +const SEND_INTERACTIVE_OPERATION = 'send_interactive' + +/** + * A stored subblock value counts as configured when the user put something in it — + * either a JSON array with entries or a raw string (which may be an unresolved + * `` reference, so it must not be JSON-parsed to be recognized). + * An empty array literal is what the editor leaves behind after clearing a field, + * so it reads as unconfigured. + */ +function isConfigured(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0 + if (typeof value !== 'string') return false + const trimmed = value.trim() + return trimmed.length > 0 && trimmed !== '[]' +} + +/** + * Backfills `interactiveType` on WhatsApp `send_interactive` blocks saved before that + * discriminator existed. + * + * `buttons` and `sections` are now gated on `interactiveType`, but subblock defaults are + * only seeded when a block is first added to a workflow — never backfilled onto saved + * state. So a pre-existing block carries no value, both conditions evaluate false, and the + * serializer drops the configured payload: the tool then fails with "Provide either buttons + * (reply buttons) or sections (list)". Worse, opening such a block in the editor lets the + * dropdown seed `button` (it writes its default whenever the stored value is empty), which + * silently hides a list block's sections. This runs at load — ahead of that seeding, and + * across both live and deployed state — so the stored payload decides the variant instead. + * + * Precedence mirrors the tool's own `buttons ? 'button' : 'list'`: a block that somehow has + * both configured (which the tool rejected then and now) resolves to `button`. + */ +export function backfillWhatsAppInteractiveType(blocks: Record): { + blocks: Record + migrated: boolean +} { + const subBlockType = getBlock(WHATSAPP_BLOCK_TYPE)?.subBlocks?.find( + (config) => config.id === INTERACTIVE_TYPE_ID + )?.type + + if (!subBlockType) return { blocks, migrated: false } + + let anyMigrated = false + const result: Record = {} + + for (const [blockId, block] of Object.entries(blocks)) { + const subBlocks = block.subBlocks + if ( + block.type !== WHATSAPP_BLOCK_TYPE || + !subBlocks || + subBlocks.operation?.value !== SEND_INTERACTIVE_OPERATION || + isConfigured(subBlocks[INTERACTIVE_TYPE_ID]?.value) + ) { + result[blockId] = block + continue + } + + const resolved = + isConfigured(subBlocks.sections?.value) && !isConfigured(subBlocks.buttons?.value) + ? 'list' + : 'button' + + anyMigrated = true + result[blockId] = { + ...block, + subBlocks: { + ...subBlocks, + [INTERACTIVE_TYPE_ID]: { + id: INTERACTIVE_TYPE_ID, + type: subBlockType, + value: resolved, + }, + }, + } + + logger.info('Backfilled WhatsApp interactiveType', { + blockId: block.id, + interactiveType: resolved, + }) + } + + return { blocks: result, migrated: anyMigrated } +} diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 7b925510065..3bfe35d3de7 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -29,6 +29,10 @@ export interface ChatDeployPayload { password?: string | null allowedEmails?: string[] outputConfigs?: Array<{ blockId: string; path: string }> + /** When true, public SSE may expose thinking if the client also opts into agent-events-v1. */ + includeThinking?: boolean + /** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */ + includeToolCalls?: boolean workspaceId?: string | null } @@ -60,6 +64,8 @@ export async function performChatDeploy( password, allowedEmails = [], outputConfigs = [], + includeThinking = false, + includeToolCalls = false, } = params const customizations = { @@ -141,6 +147,8 @@ export async function performChatDeploy( password: passwordToStore, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, + includeThinking, + includeToolCalls, updatedAt: new Date(), }) .where(eq(chat.id, chatId)) @@ -159,6 +167,8 @@ export async function performChatDeploy( password: encryptedPassword, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, + includeThinking, + includeToolCalls, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 0fff85f8fd7..584d96cc566 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -28,6 +28,7 @@ import { backfillCanonicalModes, migrateSubblockIds, } from '@/lib/workflows/migrations/subblock-migrations' +import { backfillWhatsAppInteractiveType } from '@/lib/workflows/migrations/whatsapp-interactive-type' import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' @@ -277,6 +278,11 @@ const applyBlockMigrations = createMigrationPipeline([ return { ...ctx, blocks, migrated: ctx.migrated || migrated } }, + (ctx) => { + const { blocks, migrated } = backfillWhatsAppInteractiveType(ctx.blocks) + return { ...ctx, blocks, migrated: ctx.migrated || migrated } + }, + async (ctx) => { const { blocks, migrated } = await migrateCredentialIds( ctx.blocks, @@ -460,8 +466,9 @@ async function migrateCredentialIds( /** * Load workflow from normalized tables and apply all block migrations * (credential ID rewrites, agent message migration, subblock ID migrations, - * canonical-mode backfill, tool sanitization). Returns null if the workflow - * has not been migrated to normalized tables yet. + * WhatsApp interactive-type backfill, canonical-mode backfill, tool + * sanitization). Returns null if the workflow has not been migrated to + * normalized tables yet. */ export async function loadWorkflowFromNormalizedTables( workflowId: string, diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts index 9c5ebc167e1..62f49c01b57 100644 --- a/apps/sim/lib/workflows/skills/operations.test.ts +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -4,10 +4,17 @@ import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +const { getEditableSkillIdsMock } = vi.hoisted(() => ({ + getEditableSkillIdsMock: vi.fn(), +})) + vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) -vi.mock('@sim/utils/id', () => ({ generateShortId: () => 'gen-id' })) +vi.mock('@sim/utils/id', () => ({ generateId: () => 'gen-uuid', generateShortId: () => 'gen-id' })) +vi.mock('@/lib/skills/access', () => ({ + getEditableSkillIds: getEditableSkillIdsMock, +})) -import { listSkills } from '@/lib/workflows/skills/operations' +import { listSkills, listSkillsForUser } from '@/lib/workflows/skills/operations' describe('listSkills includeBuiltins', () => { beforeEach(() => { @@ -39,3 +46,76 @@ describe('listSkills includeBuiltins', () => { expect(result.some((s) => s.id.startsWith('builtin-'))).toBe(false) }) }) + +describe('listSkillsForUser', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + getEditableSkillIdsMock.mockResolvedValue({ + canAdminWorkspace: false, + editorSkillIds: new Set(), + }) + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('returns every workspace skill tagged with edit access from editor rows', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-mine', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, + { id: 'sk-other', name: 'other', description: 'd', content: 'c', workspaceId: 'ws-1' }, + ]) + getEditableSkillIdsMock.mockResolvedValue({ + canAdminWorkspace: false, + editorSkillIds: new Set(['sk-mine']), + }) + + const result = await listSkillsForUser({ + workspaceId: 'ws-1', + userId: 'user-1', + includeBuiltins: false, + }) + + expect(result).toHaveLength(2) + expect(result.find((s) => s.id === 'sk-mine')).toMatchObject({ canEdit: true }) + expect(result.find((s) => s.id === 'sk-other')).toMatchObject({ canEdit: false }) + }) + + it('tags every skill editable for workspace admins', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-1', name: 'one', description: 'd', content: 'c', workspaceId: 'ws-1' }, + { id: 'sk-2', name: 'two', description: 'd', content: 'c', workspaceId: 'ws-1' }, + ]) + getEditableSkillIdsMock.mockResolvedValue({ + canAdminWorkspace: true, + editorSkillIds: new Set(), + }) + + const result = await listSkillsForUser({ + workspaceId: 'ws-1', + userId: 'admin-1', + includeBuiltins: false, + }) + + expect(result.every((s) => s.canEdit)).toBe(true) + }) + + it('always passes builtin skills through as non-editable', async () => { + const result = await listSkillsForUser({ workspaceId: 'ws-1', userId: 'user-1' }) + + expect(result.length).toBeGreaterThan(0) + expect(result.every((s) => s.id.startsWith('builtin-') && s.canEdit === false)).toBe(true) + }) + + it('lets a workspace skill sharing a builtin name override it for everyone', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-research', name: 'research', description: 'd', content: 'c', workspaceId: 'ws-1' }, + ]) + + const result = await listSkillsForUser({ workspaceId: 'ws-1', userId: 'user-1' }) + + expect(result.some((s) => s.id === 'sk-research')).toBe(true) + expect(result.some((s) => s.id === 'builtin-research')).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 7e0fcfb4c2f..4746cc0d5f7 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -1,15 +1,17 @@ import { db } from '@sim/db' -import { skill } from '@sim/db/schema' +import { skill, skillMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' +import { generateId, generateShortId } from '@sim/utils/id' import { and, desc, eq, ne } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' +import { getEditableSkillIds } from '@/lib/skills/access' import { BUILTIN_SKILLS, type BuiltinSkill, getBuiltinSkillById, isBuiltinSkillId, } from '@/lib/workflows/skills/builtin-skills' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsOperations') @@ -57,6 +59,46 @@ export async function listSkills(params: { workspaceId: string; includeBuiltins? return [...builtins, ...dbRows] } +/** A skill row tagged with whether the caller can edit it (always false on builtins). */ +export type SkillWithAccess = typeof skill.$inferSelect & { canEdit: boolean } + +/** + * List every skill in the workspace, each tagged with whether the caller can + * edit it: workspace admins can edit all; others can edit skills where they + * hold an explicit editor row. Everyone in the workspace sees and uses every + * skill. Built-in template skills are code-only and never editable. + * + * Pass `workspaceAccess` when the caller already resolved it to skip a + * redundant lookup. + */ +export async function listSkillsForUser(params: { + workspaceId: string + userId: string + includeBuiltins?: boolean + workspaceAccess?: WorkspaceAccess +}): Promise { + const [dbRows, access] = await Promise.all([ + listSkills({ workspaceId: params.workspaceId, includeBuiltins: false }), + getEditableSkillIds(params.workspaceId, params.userId, { + workspaceAccess: params.workspaceAccess, + }), + ]) + + const tagged: SkillWithAccess[] = dbRows.map((row) => ({ + ...row, + canEdit: access.canAdminWorkspace || access.editorSkillIds.has(row.id), + })) + + if (params.includeBuiltins === false) return tagged + + // A workspace skill that shares a built-in's name overrides it for everyone. + const dbNames = new Set(tagged.map((r) => r.name.toLowerCase())) + const builtins: SkillWithAccess[] = BUILTIN_SKILLS.filter( + (b) => !dbNames.has(b.name.toLowerCase()) + ).map((b) => ({ ...builtinSkillRow(params.workspaceId, b), canEdit: false })) + return [...builtins, ...tagged] +} + /** * Fetch a single skill by id, scoped to a workspace. Built-in template skills * resolve from code; otherwise returns the DB row, or null when the skill does @@ -112,7 +154,11 @@ export interface TouchedSkill { } export interface UpsertSkillsResult { - /** Every skill in the workspace after the upsert, ordered by createdAt desc. */ + /** + * Every skill in the workspace after the upsert, ordered by createdAt desc. + * Empty when `returnSkills: false` — callers that re-fetch a filtered list + * themselves opt out so the transaction never re-reads full content bodies. + */ skills: Awaited> /** Only the skills this upsert created or updated, tagged with the operation. */ touched: TouchedSkill[] @@ -125,13 +171,14 @@ export interface UpsertSkillsResult { export async function upsertSkills(params: { skills: Array<{ id?: string - name: string - description: string - content: string + name?: string + description?: string + content?: string }> workspaceId: string userId: string requestId?: string + returnSkills?: boolean }): Promise { const { skills, workspaceId, userId, requestId = generateRequestId() } = params @@ -147,41 +194,53 @@ export async function upsertSkills(params: { const nowTime = new Date() if (s.id) { - const existingSkill = await tx + // Id-carrying items are updates and never fall through to a create: the + // caller's authorization partitioned on resolvability, so a vanished id + // must surface as not-found rather than an ungated (re-)create. + const [current] = await tx .select() .from(skill) .where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId))) .limit(1) - if (existingSkill.length > 0) { - if (s.name !== existingSkill[0].name) { - const nameConflict = await tx - .select({ id: skill.id }) - .from(skill) - .where( - and(eq(skill.workspaceId, workspaceId), eq(skill.name, s.name), ne(skill.id, s.id)) - ) - .limit(1) - - if (nameConflict.length > 0) { - throw new Error(`A skill with the name "${s.name}" already exists in this workspace`) - } - } + if (!current) { + throw new Error(`Skill not found: ${s.id}`) + } + + // Partial update: omitted fields keep their current values, so a + // sharing-only toggle can never clobber a concurrent content edit. + const nextName = s.name ?? current.name + if (nextName !== current.name) { + const nameConflict = await tx + .select({ id: skill.id }) + .from(skill) + .where( + and(eq(skill.workspaceId, workspaceId), eq(skill.name, nextName), ne(skill.id, s.id)) + ) + .limit(1) - await tx - .update(skill) - .set({ - name: s.name, - description: s.description, - content: s.content, - updatedAt: nowTime, - }) - .where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId))) - - touched.push({ id: s.id, name: s.name, operation: 'updated' }) - logger.info(`[${requestId}] Updated skill ${s.id}`) - continue + if (nameConflict.length > 0) { + throw new Error(`The skill name "${nextName}" is unavailable in this workspace`) + } } + + await tx + .update(skill) + .set({ + name: nextName, + description: s.description ?? current.description, + content: s.content ?? current.content, + updatedAt: nowTime, + }) + .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}`) + continue + } + + if (!s.name || !s.description || !s.content) { + throw new Error('Skill name, description, and content are required to create a skill') } const duplicateName = await tx @@ -191,7 +250,7 @@ export async function upsertSkills(params: { .limit(1) if (duplicateName.length > 0) { - throw new Error(`A skill with the name "${s.name}" already exists in this workspace`) + throw new Error(`The skill name "${s.name}" is unavailable in this workspace`) } const newId = generateShortId() @@ -206,10 +265,25 @@ export async function upsertSkills(params: { updatedAt: nowTime, }) + // The creator becomes an editor; workspace admins are derived editors + // with no rows, and everyone in the workspace can already use the skill. + await tx.insert(skillMember).values({ + id: generateId(), + skillId: newId, + userId, + invitedBy: userId, + createdAt: nowTime, + updatedAt: nowTime, + }) + touched.push({ id: newId, name: s.name, operation: 'created' }) logger.info(`[${requestId}] Created skill "${s.name}"`) } + if (params.returnSkills === false) { + return { skills: [], touched } + } + const resultSkills = await tx .select() .from(skill) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts new file mode 100644 index 00000000000..b3c17aeb694 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + AGENT_STREAM_PROTOCOL_HEADER, + AGENT_STREAM_PROTOCOL_V1, + clientAcceptsAgentStreamProtocol, + isChatChunkFrame, + isChatChunkResetFrame, + isChatToolFrame, + shouldEmitAgentStreamEvents, +} from '@/lib/workflows/streaming/agent-stream-protocol' + +function headers(init?: Record): Headers { + return new Headers(init) +} + +describe('clientAcceptsAgentStreamProtocol', () => { + it('depends on the header alone, never on a deployment policy', () => { + expect( + clientAcceptsAgentStreamProtocol( + headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }) + ) + ).toBe(true) + expect( + clientAcceptsAgentStreamProtocol(headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' V1 ' })) + ).toBe(false) + expect(clientAcceptsAgentStreamProtocol(headers())).toBe(false) + }) +}) + +describe('tool frame guard', () => { + const base = { event: 'tool', blockId: 'agent-1', phase: 'end', id: 'call_1', name: 'search' } + + it('accepts every documented terminal status and a start frame without one', () => { + expect(isChatToolFrame({ ...base, phase: 'start' })).toBe(true) + for (const status of ['success', 'error', 'cancelled']) { + expect(isChatToolFrame({ ...base, status })).toBe(true) + } + }) + + it('rejects an unrecognized status instead of letting it settle as success', () => { + expect(isChatToolFrame({ ...base, status: 'timeout' })).toBe(false) + expect(isChatToolFrame({ ...base, status: 42 })).toBe(false) + }) +}) + +describe('chunk_reset frame guard', () => { + it('identifies reset frames and keeps them out of the chunk guard', () => { + const reset = { blockId: 'agent-1', event: 'chunk_reset' } + expect(isChatChunkResetFrame(reset)).toBe(true) + // A reset must never be appended as answer text. + expect(isChatChunkFrame(reset)).toBe(false) + + expect(isChatChunkResetFrame({ event: 'chunk_reset' })).toBe(false) + expect(isChatChunkResetFrame({ blockId: 'agent-1', chunk: 'text' })).toBe(false) + }) +}) + +describe('shouldEmitAgentStreamEvents', () => { + it('defaults to false when policy is off and header is missing', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + includeToolCalls: false, + requestHeaders: headers(), + }) + ).toBe(false) + expect( + shouldEmitAgentStreamEvents({ + includeThinking: undefined, + includeToolCalls: undefined, + requestHeaders: headers(), + }) + ).toBe(false) + }) + + it('requires the protocol header and at least one event policy', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + includeToolCalls: false, + requestHeaders: headers(), + }) + ).toBe(false) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + includeToolCalls: false, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(false) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + includeToolCalls: false, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(true) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + includeToolCalls: true, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(true) + }) + + it('accepts case-insensitive header values and comma lists', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + includeToolCalls: false, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' Agent-Events-V1 ' }), + }) + ).toBe(true) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + includeToolCalls: true, + requestHeaders: headers({ + [AGENT_STREAM_PROTOCOL_HEADER]: 'text, agent-events-v1', + }), + }) + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts new file mode 100644 index 00000000000..62f46334cd5 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts @@ -0,0 +1,219 @@ +/** + * Public agent stream protocol: header negotiation and the wire frame + * vocabulary for the public chat / simple SSE surface. + * + * Two independent things are negotiated here: + * + * 1. Client capability — {@link AGENT_STREAM_PROTOCOL_HEADER} means the client + * understands v1 framing, so answer text may stream live and be retracted + * with `chunk_reset`. No deployment policy is involved. + * 2. Event exposure — thinking frames need `deployment.includeThinking`, tool + * frames need `deployment.includeToolCalls`, and both additionally need the + * client capability above. + * + * Keeping these separate is what lets a chat with both policies off still + * stream its answer token by token, exactly as it did before agent events. + * + * Canvas draft runs (execution-events) forward the same sink as live-only + * `stream:thinking` / `stream:tool` events without the deployment policy gates; + * the executor still disables the sink when block-output PII redaction is on. + * + * Legacy clients omitting the header stay on settled final-turn text and never + * see thinking or tools. The deployed chat UI always sends the header. + * + * See docs: workflows/deployment/agent-events. + */ + +import { isToolCallEndStatus, type ToolCallEndStatus } from '@/providers/stream-events' + +export const AGENT_STREAM_PROTOCOL_HEADER = 'x-sim-stream-protocol' as const + +export const AGENT_STREAM_PROTOCOL_V1 = 'agent-events-v1' as const + +export type AgentStreamProtocol = typeof AGENT_STREAM_PROTOCOL_V1 + +/** + * Answer text. The only frame legacy clients append to the answer. + * + * Legacy clients (no protocol header) receive only settled final-turn text. + * Protocol-negotiated clients receive answer text live as it streams — + * including text from a turn that may later resolve to tool calls — reconciled + * by {@link ChatStreamChunkResetFrame} when a turn turns out to be intermediate. + */ +export interface ChatStreamChunkFrame { + blockId: string + chunk: string +} + +/** + * Negotiated agent-events streams only: the live-streamed answer text for + * `blockId` belonged to an intermediate turn (tool calls follow). Clients + * discard the block's accumulated answer text; the final turn re-streams after + * tools settle. + */ +export interface ChatStreamChunkResetFrame { + blockId: string + event: 'chunk_reset' +} + +/** Thinking / reasoning-summary delta. Thinking-policy gated; never reuses `chunk`. */ +export interface ChatStreamThinkingFrame { + blockId: string + event: 'thinking' + data: string +} + +/** Tool lifecycle (name + status only — never args or results). Tool-policy gated. */ +export interface ChatStreamToolFrame { + blockId: string + event: 'tool' + phase: 'start' | 'end' + id: string + name: string + status?: ToolCallEndStatus +} + +/** Terminal success envelope, followed by `[DONE]`. */ +export interface ChatStreamFinalFrame { + event: 'final' + data: Record +} + +/** Terminal failure, followed by `[DONE]`. Never followed by `final`. */ +export interface ChatStreamErrorFrame { + blockId?: string + event: 'error' + error: string +} + +/** Non-terminal mid-block read issue; the stream keeps going. */ +export interface ChatStreamStreamErrorFrame { + blockId?: string + event: 'stream_error' + error: string +} + +/** + * Every JSON frame the public chat / simple SSE stream can carry (the stream + * additionally ends with a literal `[DONE]` marker). The server emitters and + * the chat client both consume this union so the two cannot drift. + */ +export type ChatStreamFrame = + | ChatStreamChunkFrame + | ChatStreamChunkResetFrame + | ChatStreamThinkingFrame + | ChatStreamToolFrame + | ChatStreamFinalFrame + | ChatStreamErrorFrame + | ChatStreamStreamErrorFrame + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +/** + * Answer text frame: `{ blockId, chunk }` with no `event` discriminator. + * Positively defined so thinking/tool/terminal frames can never be appended + * into the answer by a client that checks this first. + */ +export function isChatChunkFrame(value: unknown): value is ChatStreamChunkFrame { + if (!isRecord(value)) return false + return ( + typeof value.blockId === 'string' && + typeof value.chunk === 'string' && + value.chunk.length > 0 && + value.event === undefined + ) +} + +export function isChatChunkResetFrame(value: unknown): value is ChatStreamChunkResetFrame { + if (!isRecord(value)) return false + return value.event === 'chunk_reset' && typeof value.blockId === 'string' +} + +export function isChatThinkingFrame(value: unknown): value is ChatStreamThinkingFrame { + if (!isRecord(value)) return false + return ( + value.event === 'thinking' && + typeof value.blockId === 'string' && + typeof value.data === 'string' + ) +} + +export function isChatToolFrame(value: unknown): value is ChatStreamToolFrame { + if (!isRecord(value)) return false + return ( + value.event === 'tool' && + typeof value.blockId === 'string' && + (value.phase === 'start' || value.phase === 'end') && + typeof value.id === 'string' && + value.id.length > 0 && + typeof value.name === 'string' && + value.name.length > 0 && + // An unrecognized status is a protocol violation, not a success. Rejecting + // the frame leaves the chip running for the terminal settle (which knows + // the run's real outcome) instead of rendering it green on a guess. + (value.status === undefined || isToolCallEndStatus(value.status)) + ) +} + +export function isChatFinalFrame(value: unknown): value is ChatStreamFinalFrame { + if (!isRecord(value)) return false + return value.event === 'final' && isRecord(value.data) +} + +export function isChatErrorFrame(value: unknown): value is ChatStreamErrorFrame { + if (!isRecord(value)) return false + return value.event === 'error' +} + +export function isChatStreamErrorFrame(value: unknown): value is ChatStreamStreamErrorFrame { + if (!isRecord(value)) return false + return value.event === 'stream_error' +} + +/** + * Whether the client declared it understands agent-events-v1 framing. + * + * This is a statement about the *client*, not about what a deployment may + * expose: sending the header means the client appends `chunk` and honors + * `chunk_reset`, so answer text can stream live and be retracted. Thinking and + * tool exposure are separate deployment policies on top of this. + */ +export function clientAcceptsAgentStreamProtocol( + requestHeaders: Headers | { get(name: string): string | null } +): boolean { + const raw = requestHeaders.get(AGENT_STREAM_PROTOCOL_HEADER) + if (!raw) { + return false + } + + // Allow comma-separated values / surrounding whitespace from proxies. + const tokens = raw + .split(',') + .map((token) => token.trim().toLowerCase()) + .filter(Boolean) + + return tokens.includes(AGENT_STREAM_PROTOCOL_V1) +} + +/** + * Returns true when a negotiated client may receive thinking or tool frames — + * at least one deployment policy is on and the client accepts the protocol. + * + * Drives the run-level `agentEvents` flag, which asks providers for reasoning + * summaries. Answer-text cadence does *not* depend on this: a negotiated client + * streams live text even with both policies off. Frame emitters still apply + * each independent policy before exposing its corresponding frames. + */ +export function shouldEmitAgentStreamEvents(options: { + includeThinking: boolean | null | undefined + includeToolCalls: boolean | null | undefined + requestHeaders: Headers | { get(name: string): string | null } +}): boolean { + if (options.includeThinking !== true && options.includeToolCalls !== true) { + return false + } + + return clientAcceptsAgentStreamProtocol(options.requestHeaders) +} diff --git a/apps/sim/lib/workflows/streaming/forward-agent-stream-events.test.ts b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.test.ts new file mode 100644 index 00000000000..ca8637730ee --- /dev/null +++ b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + forwardAgentStreamToExecutionEvents, + shouldForwardAnswerTextFromSink, +} from '@/lib/workflows/streaming/forward-agent-stream-events' +import type { StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent } from '@/providers/stream-events' + +function makeStreamingExec( + onSubscribe: (handler: (event: AgentStreamEvent) => void | Promise) => void, + unsubscribe = vi.fn() +): StreamingExecution { + return { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + subscribe: (sink: { onEvent: (event: AgentStreamEvent) => void | Promise }) => { + onSubscribe(sink.onEvent) + return unsubscribe + }, + } as StreamingExecution +} + +describe('forwardAgentStreamToExecutionEvents', () => { + it('subscribes in the sync window and maps sink events to execution events', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const unsubscribe = vi.fn() + const sendEvent = vi.fn() + + const unsub = forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }, unsubscribe), + { blockId: 'agent-1', executionId: 'exec-1', workflowId: 'wf-1', sendEvent } + ) + + expect(sinkHandler).toBeTypeOf('function') + + await sinkHandler!({ type: 'thinking_delta', text: 'plan ' }) + await sinkHandler!({ type: 'tool_call_start', id: 't1', name: 'http_request' }) + await sinkHandler!({ + type: 'tool_call_end', + id: 't1', + name: 'http_request', + status: 'success', + }) + await sinkHandler!({ type: 'text_delta', text: 'hi', turn: 'final' }) + + expect(sendEvent).toHaveBeenCalledTimes(3) + expect(sendEvent.mock.calls[0][0]).toMatchObject({ + type: 'stream:thinking', + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', text: 'plan ' }, + }) + expect(sendEvent.mock.calls[1][0]).toMatchObject({ + type: 'stream:tool', + data: { blockId: 'agent-1', phase: 'start', id: 't1', name: 'http_request' }, + }) + expect(sendEvent.mock.calls[2][0]).toMatchObject({ + type: 'stream:tool', + data: { blockId: 'agent-1', phase: 'end', id: 't1', name: 'http_request', status: 'success' }, + }) + + unsub() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('does not forward text deltas by default (answer text rides the byte stream)', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const sendEvent = vi.fn() + + forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }), + { blockId: 'agent-1', executionId: 'exec-1', workflowId: 'wf-1', sendEvent } + ) + + await sinkHandler!({ type: 'text_delta', text: 'answer', turn: 'final' }) + await sinkHandler!({ type: 'text_delta', text: 'preamble', turn: 'intermediate' }) + await sinkHandler!({ type: 'turn_end', turn: 'intermediate' }) + expect(sendEvent).not.toHaveBeenCalled() + }) + + it('forwardAnswerText streams live text and resets intermediate turns', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const sendEvent = vi.fn() + + forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }), + { + blockId: 'agent-1', + executionId: 'exec-1', + workflowId: 'wf-1', + sendEvent, + forwardAnswerText: true, + } + ) + + // Turn 1: preamble text, then tools follow → reset. + await sinkHandler!({ type: 'text_delta', text: 'Let me check…', turn: 'pending' }) + await sinkHandler!({ type: 'turn_end', turn: 'intermediate' }) + // Turn 2: final answer. + await sinkHandler!({ type: 'text_delta', text: 'Answer', turn: 'pending' }) + await sinkHandler!({ type: 'turn_end', turn: 'final' }) + // Intermediate-tagged deltas never forward. + await sinkHandler!({ type: 'text_delta', text: 'hidden', turn: 'intermediate' }) + + const calls = sendEvent.mock.calls.map(([event]) => ({ type: event.type, data: event.data })) + expect(calls).toEqual([ + { type: 'stream:chunk', data: { blockId: 'agent-1', chunk: 'Let me check…' } }, + { type: 'stream:chunk_reset', data: { blockId: 'agent-1' } }, + { type: 'stream:chunk', data: { blockId: 'agent-1', chunk: 'Answer' } }, + ]) + }) + + it('skips chunk_reset when no text was forwarded for the turn', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const sendEvent = vi.fn() + + forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }), + { + blockId: 'agent-1', + executionId: 'exec-1', + workflowId: 'wf-1', + sendEvent, + forwardAnswerText: true, + } + ) + + // Tool-only turn (no text) resolves intermediate — nothing to clear. + await sinkHandler!({ type: 'turn_end', turn: 'intermediate' }) + expect(sendEvent).not.toHaveBeenCalled() + }) + + it('no-ops when subscribe is absent', () => { + const streamingExec = { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + } as StreamingExecution + + const unsub = forwardAgentStreamToExecutionEvents(streamingExec, { + blockId: 'agent-1', + executionId: 'exec-1', + workflowId: 'wf-1', + sendEvent: vi.fn(), + }) + expect(() => unsub()).not.toThrow() + }) +}) + +describe('shouldForwardAnswerTextFromSink', () => { + it('requires a sink and an untransformed client stream', () => { + const base = { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + } as StreamingExecution + const subscribe = () => () => {} + + expect(shouldForwardAnswerTextFromSink(base)).toBe(false) + expect(shouldForwardAnswerTextFromSink({ ...base, subscribe })).toBe(true) + expect( + shouldForwardAnswerTextFromSink({ ...base, subscribe, clientStreamTransformed: true }) + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/streaming/forward-agent-stream-events.ts b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.ts new file mode 100644 index 00000000000..0d270b64fe4 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.ts @@ -0,0 +1,117 @@ +/** + * Bridges an agent-events provider sink onto the execution-events SSE + * vocabulary (`stream:thinking` / `stream:tool`, and optionally live answer + * text as `stream:chunk` + `stream:chunk_reset`). Shared by the workflow + * execute route and the HITL resume manager so the mapping cannot drift. + * + * Must be called in the caller's sync window (before awaiting the text + * reader) so the executor pump registers the sink before pulling provider + * chunks. + */ + +import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' +import type { StreamingExecution } from '@/executor/types' + +export interface ForwardAgentStreamEventsOptions { + blockId: string + executionId: string + workflowId: string + sendEvent: (event: ExecutionEvent) => void | Promise + /** + * When true, answer text deltas forward live as `stream:chunk` events and an + * intermediate `turn_end` forwards as `stream:chunk_reset`. The caller MUST + * then stop emitting `stream:chunk` from the block's byte stream, or clients + * receive the final turn's text twice. Never enable for response-format + * projected streams ({@link StreamingExecution.clientStreamTransformed}). + */ + forwardAnswerText?: boolean +} + +/** + * Returns true when the caller should source `stream:chunk` events from the + * sink (via {@link forwardAgentStreamToExecutionEvents} with + * `forwardAnswerText`) instead of the block's byte stream. + */ +export function shouldForwardAnswerTextFromSink(streamingExec: StreamingExecution): boolean { + return Boolean(streamingExec.subscribe) && streamingExec.clientStreamTransformed !== true +} + +/** + * Subscribes to the streaming execution's agent-events sink and forwards + * thinking deltas and tool lifecycle as execution events. With + * {@link ForwardAgentStreamEventsOptions.forwardAnswerText}, answer text also + * forwards live (`pending` deltas stream as the model generates; a + * `chunk_reset` clears turns that resolve to tool calls). Returns an + * unsubscribe function (no-op when the execution has no sink). + */ +export function forwardAgentStreamToExecutionEvents( + streamingExec: StreamingExecution, + options: ForwardAgentStreamEventsOptions +): () => void { + if (!streamingExec.subscribe) { + return () => {} + } + + const { blockId, executionId, workflowId, sendEvent, forwardAnswerText = false } = options + let emittedSinceReset = false + + return streamingExec.subscribe({ + onEvent: async (event) => { + if (event.type === 'thinking_delta') { + await sendEvent({ + type: 'stream:thinking', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, text: event.text }, + }) + return + } + if (event.type === 'tool_call_start') { + await sendEvent({ + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, phase: 'start', id: event.id, name: event.name }, + }) + return + } + if (event.type === 'tool_call_end') { + await sendEvent({ + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, phase: 'end', id: event.id, name: event.name, status: event.status }, + }) + return + } + if (!forwardAnswerText) { + return + } + if (event.type === 'text_delta') { + if (event.turn === 'intermediate' || !event.text) return + emittedSinceReset = true + await sendEvent({ + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, chunk: event.text }, + }) + return + } + if (event.type === 'turn_end' && event.turn === 'intermediate' && emittedSinceReset) { + emittedSinceReset = false + await sendEvent({ + type: 'stream:chunk_reset', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId }, + }) + } + }, + }) +} diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index f802dc5bfa4..7d1a25d4ee8 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -4,7 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readSSEStream } from '@/lib/core/utils/sse' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' -import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' const { mockDownloadFile } = vi.hoisted(() => ({ mockDownloadFile: vi.fn(), @@ -604,3 +607,759 @@ describe('createStreamingResponse', () => { await expect(readSSEStream(stream)).resolves.toBe('ok') }) }) + +describe('final envelope tool payloads', () => { + const agentOutput = { + content: 'Done', + toolCalls: { + count: 1, + list: [ + { + name: 'get_weather', + duration: 12, + arguments: { city: 'private' }, + result: { temperature: 72 }, + }, + ], + }, + } + + function executeFnReturning(output: Record) { + return async () => + ({ + success: true, + output, + logs: [ + { + blockId: 'agent-1', + output, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + }) as any + } + + it('redacts tool arguments and results for public chat', async () => { + // No outputConfigs means the whole block output rides the envelope, which + // must not become a side channel around the tool-frame gate. + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: true, selectedOutputs: [] }, + executeFn: executeFnReturning(agentOutput), + }) + + const events = await collectSSEEvents(stream) + const final = events.find((event) => event.event === 'final') + const toolCall = (final?.data as any).output.toolCalls.list[0] + + expect(toolCall).toEqual({ name: 'get_weather', duration: 12 }) + expect(JSON.stringify(final)).not.toContain('private') + expect(JSON.stringify(final)).not.toContain('72') + }) + + /** + * A deployment almost always selects outputs, so redaction that only covered + * the empty-selection branch would be dead in the case it exists for. + */ + it('redacts tool payloads when the deployment selects toolCalls directly', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: true, selectedOutputs: ['block_toolCalls'] }, + executeFn: async ({ onBlockComplete }) => { + const toolOnlyOutput = { toolCalls: agentOutput.toolCalls } + await onBlockComplete('block', toolOnlyOutput) + return { + success: true, + output: {}, + logs: [ + { + blockId: 'block', + output: toolOnlyOutput, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + // The payload rides the chunk frame, not `final`, so assert on the whole + // stream — sanitizing only the envelope would still leak here. + const events = await collectSSEEvents(stream) + const serialized = JSON.stringify(events) + + expect(serialized).not.toContain('private') + expect(serialized).not.toContain('72') + expect(serialized).toContain('get_weather') + }) + + it('keeps tool results for the authenticated workflow API', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: false, selectedOutputs: [] }, + executeFn: executeFnReturning(agentOutput), + }) + + const events = await collectSSEEvents(stream) + const final = events.find((event) => event.event === 'final') + const toolCall = (final?.data as any).output.toolCalls.list[0] + + expect(toolCall.arguments).toEqual({ city: 'private' }) + expect(toolCall.result).toEqual({ temperature: 72 }) + }) +}) + +describe('agent stream protocol response headers', () => { + const requestHeaders = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + + it('echoes the protocol whenever the client negotiated it', () => { + // v1 framing (live text + chunk_reset) is in effect on client capability + // alone, so the echo must not depend on the event policies. + expect(agentStreamProtocolResponseHeaders({ requestHeaders })).toEqual({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + }) + + it('stays inactive for legacy clients and when no headers are supplied', () => { + expect(agentStreamProtocolResponseHeaders({ requestHeaders: new Headers() })).toEqual({}) + expect(agentStreamProtocolResponseHeaders({})).toEqual({}) + }) +}) + +describe('createStreamingResponse agent-events-v1', () => { + beforeEach(() => { + vi.clearAllMocks() + clearLargeValueCacheForTests() + }) + + function createAgentStreamExecuteFn(options: { + thinking?: string[] + answer: string + fail?: boolean + tools?: Array< + | { type: 'tool_call_start'; id: string; name: string; args?: unknown } + | { + type: 'tool_call_end' + id: string + name: string + status: string + result?: unknown + } + > + }) { + return async ({ + onStream, + abortSignal, + }: { + onStream: (streamingExec: any) => Promise + onBlockComplete: (blockId: string, output: unknown) => Promise + abortSignal: AbortSignal + }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => { + sink = undefined + } + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: options.answer }, + logs: [], + metadata: {}, + }, + }) + + if (options.fail) { + textController.error(new Error('provider reset')) + await onStreamPromise.catch(() => {}) + throw new Error('provider reset') + } + + for (const text of options.thinking ?? []) { + await sink?.onEvent({ type: 'thinking_delta', text }) + } + for (const toolEvent of options.tools ?? []) { + await sink?.onEvent(toolEvent) + } + // Mirror the pump: text dispatches to the sink first, then projects to bytes. + await sink?.onEvent({ type: 'text_delta', text: options.answer, turn: 'final' }) + textController.enqueue(new TextEncoder().encode(options.answer)) + textController.close() + await onStreamPromise + + expect(abortSignal).toBeDefined() + + return { + success: true, + output: { content: options.answer }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + } + } + + async function collectSSEPayloads(stream: ReadableStream): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) { + buffer += decoder.decode() + break + } + buffer += decoder.decode(value, { stream: true }) + } + return buffer + .split('\n\n') + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.startsWith('data: ')) + .map((chunk) => chunk.slice(6)) + } + + it('legacy path without protocol header stays text-only', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { + includeThinking: true, + includeToolCalls: true, + selectedOutputs: ['agent-1_content'], + }, + // No requestHeaders → gate closed + executeFn: createAgentStreamExecuteFn({ + thinking: ['secret thought'], + answer: 'Hello', + tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }], + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events.some((event) => event.event === 'tool')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Hello' }) + expect(events.some((event) => event.event === 'final')).toBe(true) + }) + + it('header + includeThinking emits thinking on data and answer on chunk', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + includeToolCalls: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + thinking: ['hmm ', 'yes'], + answer: 'Answer', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.event === 'thinking')).toEqual([ + { blockId: 'agent-1', event: 'thinking', data: 'hmm ' }, + { blockId: 'agent-1', event: 'thinking', data: 'yes' }, + ]) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + expect(events.some((event) => event.event === 'final')).toBe(true) + }) + + it('includeToolCalls emits tool start/end frames without exposing args or results', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + includeToolCalls: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + answer: 'Done', + tools: [ + { + type: 'tool_call_start', + id: 'toolu_1', + name: 'get_weather', + args: { city: 'private' }, + }, + { + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + result: { temperature: 72 }, + }, + ], + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.event === 'tool')).toEqual([ + { + blockId: 'agent-1', + event: 'tool', + phase: 'start', + id: 'toolu_1', + name: 'get_weather', + }, + { + blockId: 'agent-1', + event: 'tool', + phase: 'end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }, + ]) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Done' }) + expect( + events.some( + (event) => + typeof event.chunk === 'string' && + (String(event.chunk).includes('toolu_1') || String(event.chunk).includes('get_weather')) + ) + ).toBe(false) + }) + + it('tool-only policy streams pending text live and resets intermediate turns', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + includeToolCalls: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => {} + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'Final answer' }, + logs: [], + metadata: {}, + }, + } as any) + + // Turn 1: live preamble, then tools follow → intermediate turn_end. + await sink?.onEvent({ type: 'text_delta', text: 'Checking…', turn: 'pending' }) + await sink?.onEvent({ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }) + await sink?.onEvent({ type: 'turn_end', turn: 'intermediate' }) + await sink?.onEvent({ + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }) + // Turn 2: live final answer; pump projects it to bytes at turn_end. + await sink?.onEvent({ type: 'text_delta', text: 'Final ', turn: 'pending' }) + await sink?.onEvent({ type: 'text_delta', text: 'answer', turn: 'pending' }) + await sink?.onEvent({ type: 'turn_end', turn: 'final' }) + textController.enqueue(new TextEncoder().encode('Final answer')) + textController.close() + await onStreamPromise + + return { + success: true, + output: { content: 'Final answer' }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + + // Live text arrives as chunk frames in stream order, with a reset between turns. + const answerFlow = events + .filter((event) => event.chunk !== undefined || event.event === 'chunk_reset') + .map((event) => (event.event === 'chunk_reset' ? 'RESET' : event.chunk)) + expect(answerFlow).toEqual(['Checking…', 'RESET', 'Final ', 'answer']) + + // The byte-path flush of the same final text must not duplicate chunk frames. + expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual( + ['Checking…', 'Final ', 'answer'] + ) + }) + + it('streams answer text live for a negotiated client with both policies off', async () => { + // Answer cadence follows client capability, not event policy: a chat with + // thinking and tools both disabled must still stream token by token, the + // way it did before streaming tool loops existed. + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + includeToolCalls: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => {} + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'Final answer' }, + logs: [], + metadata: {}, + }, + } as any) + + await sink?.onEvent({ type: 'thinking_delta', text: 'secret reasoning' }) + await sink?.onEvent({ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }) + await sink?.onEvent({ type: 'turn_end', turn: 'intermediate' }) + await sink?.onEvent({ + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }) + await sink?.onEvent({ type: 'text_delta', text: 'Final ', turn: 'pending' }) + await sink?.onEvent({ type: 'text_delta', text: 'answer', turn: 'pending' }) + await sink?.onEvent({ type: 'turn_end', turn: 'final' }) + textController.enqueue(new TextEncoder().encode('Final answer')) + textController.close() + await onStreamPromise + + return { + success: true, + output: { content: 'Final answer' }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + + expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual( + ['Final ', 'answer'] + ) + // Capability alone must not expose either gated event type. + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events.some((event) => event.event === 'tool')).toBe(false) + }) + + it('dual gate keeps byte-path chunks for response-format transformed streams', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + includeToolCalls: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => {} + }, + clientStreamTransformed: true, + execution: { + blockId: 'agent-1', + success: true, + output: { content: '{"answer":"extracted"}' }, + logs: [], + metadata: {}, + }, + } as any) + + // Sink text must NOT become chunk frames — bytes are a different projection. + await sink?.onEvent({ type: 'text_delta', text: '{"answer":"', turn: 'pending' }) + await sink?.onEvent({ type: 'text_delta', text: 'extracted"}', turn: 'pending' }) + await sink?.onEvent({ type: 'turn_end', turn: 'final' }) + textController.enqueue(new TextEncoder().encode('extracted')) + textController.close() + await onStreamPromise + + return { + success: true, + output: { content: '{"answer":"extracted"}' }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual( + ['extracted'] + ) + expect(events.some((event) => event.event === 'chunk_reset')).toBe(false) + }) + + it('includeThinking without includeToolCalls does not emit tool frames', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + includeToolCalls: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + answer: 'Answer', + tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }], + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'tool')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + }) + + it('includeToolCalls without includeThinking does not emit thinking', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + includeToolCalls: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + thinking: ['should not appear'], + answer: 'Answer', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + }) + + it('provider failure emits one terminal error, no final, then [DONE]', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: {}, + executeFn: createAgentStreamExecuteFn({ + answer: 'partial', + fail: true, + }), + }) + + const payloads = await collectSSEPayloads(stream) + const events = payloads + .filter((payload) => payload !== '[DONE]' && payload !== '"[DONE]"') + .map((payload) => JSON.parse(payload) as Record) + + expect(events.filter((event) => event.event === 'error')).toHaveLength(1) + expect(events.some((event) => event.event === 'final')).toBe(false) + expect(payloads.some((payload) => payload === '[DONE]' || payload === '"[DONE]"')).toBe(true) + }) + + it('requestSignal abort propagates to executeFn abortSignal', async () => { + const requestAbort = new AbortController() + let sawAbort = false + + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestSignal: requestAbort.signal, + streamConfig: {}, + executeFn: async ({ abortSignal }) => { + requestAbort.abort() + sawAbort = abortSignal.aborted + return { + success: false, + status: 'cancelled', + output: {}, + logs: [], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + expect(sawAbort).toBe(true) + expect(events.some((event) => event.event === 'final')).toBe(false) + expect(events).toContainEqual({ event: 'error', error: 'Client cancelled request' }) + }) + + it('thinking never enters streamedChunks / log content rewrite', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + let rewrittenContent: string | undefined + + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + includeToolCalls: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: any) => { + sink = nextSink + return () => { + sink = undefined + } + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'visible' }, + logs: [], + metadata: {}, + }, + } as any) + + await sink?.onEvent({ type: 'thinking_delta', text: 'PRIVATE_THINKING' }) + textController.enqueue(new TextEncoder().encode('visible')) + textController.close() + await onStreamPromise + + return { + success: true, + output: {}, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + const answerChunks = events.filter((event) => typeof event.chunk === 'string') + expect(answerChunks.every((event) => !String(event.chunk).includes('PRIVATE_THINKING'))).toBe( + true + ) + expect(events).toContainEqual({ + blockId: 'agent-1', + event: 'thinking', + data: 'PRIVATE_THINKING', + }) + // Force consumption of stream so log rewrite runs + expect(events.some((event) => event.event === 'final')).toBe(true) + void rewrittenContent + }) +}) diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index c848854346f..96beb1c1da9 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike, omit } from '@sim/utils/object' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { extractBlockIdFromOutputId, @@ -24,8 +25,22 @@ import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' +import { + AGENT_STREAM_PROTOCOL_HEADER, + AGENT_STREAM_PROTOCOL_V1, + type ChatStreamChunkFrame, + type ChatStreamChunkResetFrame, + type ChatStreamErrorFrame, + type ChatStreamFinalFrame, + type ChatStreamStreamErrorFrame, + type ChatStreamThinkingFrame, + type ChatStreamToolFrame, + clientAcceptsAgentStreamProtocol, +} from '@/lib/workflows/streaming/agent-stream-protocol' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' +import type { ToolCallEndStatus } from '@/providers/stream-events' +import { DEFAULT_MAX_THINKING_CHARS } from '@/providers/stream-pump' /** * Extended streaming execution type that includes blockId on the execution. @@ -41,6 +56,22 @@ const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] const SELECTED_OUTPUT_TOO_LARGE_MESSAGE = 'Selected output is too large to inline; select a nested field or use pagination/preview.' +/** + * Simple SSE stream contract — frame shapes are the `ChatStreamFrame` union in + * `agent-stream-protocol.ts`, consumed by both these emitters and the chat client: + * - Answer text: `{ blockId, chunk }` only (`chunk` is forever answer text). + * Legacy clients get settled final-turn text; protocol-negotiated clients get + * answer text live from the agent-events sink, reconciled by + * `{ blockId, event: 'chunk_reset' }` when a turn resolves to tool calls. + * - Thinking (opt-in): `{ blockId, event: 'thinking', data }` — never uses `chunk`. + * - Tool lifecycle (opt-in): `{ blockId, event: 'tool', ... }` — name/status only. + * - Success terminal: `{ event: 'final', data }` then `[DONE]`. + * - Failure terminal: exactly one `{ event: 'error', ... }` then `[DONE]`. No `final` after failure. + * - Mid-block read issues may emit non-terminal `{ event: 'stream_error', blockId, error }`. + * - Thinking never enters `streamedChunks` / log rewrite / tokenization — the + * log/tokenization source is always the byte stream (final-turn text only). + */ + interface StreamingConfig { selectedOutputs?: string[] isSecureMode?: boolean @@ -48,6 +79,10 @@ interface StreamingConfig { includeFileBase64?: boolean base64MaxBytes?: number timeoutMs?: number + /** Thinking SSE policy; still requires the negotiated agent-events protocol. */ + includeThinking?: boolean + /** Tool lifecycle SSE policy; still requires the negotiated agent-events protocol. */ + includeToolCalls?: boolean } export type StreamingExecutorFn = (callbacks: { @@ -67,9 +102,30 @@ export interface StreamingResponseOptions { workspaceId?: string workflowId?: string userId?: string + /** Incoming fetch/request abort — combined with the stream timeout. */ + requestSignal?: AbortSignal + /** Used with the independent event policies to negotiate agent-events SSE. */ + requestHeaders?: Headers | { get(name: string): string | null } executeFn: StreamingExecutorFn } +/** + * Echoes the stream protocol back when the client negotiated it, so the client + * knows v1 framing is in effect and that `chunk_reset` may arrive. Driven by + * client capability alone — a negotiated client streams live answer text even + * when both event policies are off. Merge into the SSE response alongside + * {@link SSE_HEADERS}. + */ +export function agentStreamProtocolResponseHeaders(options: { + requestHeaders?: Headers | { get(name: string): string | null } +}): Record { + if (!options.requestHeaders) return {} + if (!clientAcceptsAgentStreamProtocol(options.requestHeaders)) { + return {} + } + return { [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 } +} + interface StreamingState { streamedChunks: Map processedOutputs: Set @@ -193,6 +249,71 @@ function assertSelectedOutputBytes(value: unknown): number { return bytes } +/** Tool-call payload keys that must never ride a public `final` envelope. */ +const TOOL_PAYLOAD_KEYS = ['arguments', 'input', 'result', 'output'] as const + +function redactToolCallPayloads(toolCall: unknown): unknown { + if (!toolCall || typeof toolCall !== 'object') return toolCall + return omit(toolCall as Record, [...TOOL_PAYLOAD_KEYS]) +} + +/** + * Strips model internals from an output before it rides a simple-SSE `final` + * envelope. + * + * `thinkingContent`, intermediate `assistantContent`, and tool-call arguments + * inside `providerTiming.timeSegments` would otherwise reach clients wholesale, + * bypassing the independently gated thinking/tool frames. Timing numbers and + * tool names stay — they carry no model internals. + * + * With `redactToolPayloads` (public chat, where the caller is an anonymous end + * user rather than the workflow owner), the block's own top-level `toolCalls` + * are reduced the same way. Without it, the authenticated workflow API keeps + * returning tool results, which callers legitimately consume. + */ +function sanitizeOutputForEnvelope( + output: Record, + options: { redactToolPayloads: boolean } +): Record { + let sanitized = output + + const providerTiming = output.providerTiming as { timeSegments?: unknown } | undefined + if (providerTiming && Array.isArray(providerTiming.timeSegments)) { + sanitized = { + ...sanitized, + providerTiming: { + ...providerTiming, + timeSegments: providerTiming.timeSegments.map((segment) => { + if (!segment || typeof segment !== 'object') return segment + const { toolCalls, ...rest } = omit(segment as Record, [ + 'thinkingContent', + 'assistantContent', + ]) as Record & { toolCalls?: unknown } + return { + ...rest, + ...(Array.isArray(toolCalls) + ? { toolCalls: toolCalls.map(redactToolCallPayloads) } + : {}), + } + }), + }, + } + } + + const blockToolCalls = sanitized.toolCalls as { list?: unknown } | undefined + if (options.redactToolPayloads && blockToolCalls && Array.isArray(blockToolCalls.list)) { + sanitized = { + ...sanitized, + toolCalls: { + ...blockToolCalls, + list: blockToolCalls.list.map(redactToolCallPayloads), + }, + } + } + + return sanitized +} + async function buildMinimalResult( result: ExecutionResult, selectedOutputs: string[] | undefined, @@ -203,8 +324,12 @@ async function buildMinimalResult( includeFileBase64: boolean, base64MaxBytes: number | undefined, executionId?: string, - context: Omit = { requestId } + context: Omit & { + /** Public chat: reduce the block's own tool calls to name + lifecycle. */ + redactToolPayloads?: boolean + } = { requestId } ): Promise<{ success: boolean; error?: string; output: Record }> { + const envelopeOptions = { redactToolPayloads: context.redactToolPayloads === true } const durableContext = { workspaceId: context.workspaceId, workflowId: context.workflowId, @@ -220,7 +345,7 @@ async function buildMinimalResult( } if (result.status === 'paused') { - minimalResult.output = result.output || {} + minimalResult.output = sanitizeOutputForEnvelope(result.output || {}, envelopeOptions) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -229,7 +354,7 @@ async function buildMinimalResult( } if (!selectedOutputs?.length) { - minimalResult.output = result.output || {} + minimalResult.output = sanitizeOutputForEnvelope(result.output || {}, envelopeOptions) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -241,6 +366,23 @@ async function buildMinimalResult( return minimalResult } + /** + * Selected outputs are extracted from the sanitized block output, not the raw + * log. A deployment can select `toolCalls` or `providerTiming` directly, so + * sanitizing per selected path would leave the leak open for whichever path + * was missed; sanitizing the source closes it for every path at once. Cached + * per block because several descriptors can target the same one. + */ + const sanitizedBlockOutputs = new Map>() + const sanitizedOutputFor = (blockId: string, output: Record) => { + const cached = sanitizedBlockOutputs.get(blockId) + if (cached) return cached + + const sanitized = sanitizeOutputForEnvelope(output, envelopeOptions) + sanitizedBlockOutputs.set(blockId, sanitized) + return sanitized + } + let selectedOutputBytes = assertSelectedOutputBytes(minimalResult.output) for (const descriptor of getSelectedOutputDescriptors(selectedOutputs)) { const { blockId, path } = descriptor @@ -281,7 +423,11 @@ async function buildMinimalResult( getBase64DecodedByteBudget(remainingBytes) ), } - const value = await extractOutputValue(blockLog.output, path, extractionContext) + const value = await extractOutputValue( + sanitizedOutputFor(blockId, blockLog.output), + path, + extractionContext + ) if (value === undefined) { continue } @@ -351,14 +497,34 @@ export async function createStreamingResponse( options: StreamingResponseOptions ): Promise { const { requestId, streamConfig, executionId, executeFn } = options - const durableContext = { - workspaceId: options.workspaceId, - workflowId: options.workflowId, - executionId, - userId: options.userId, - requireDurable: Boolean(options.workspaceId && options.workflowId && executionId), - } const timeoutController = createTimeoutAbortController(streamConfig.timeoutMs) + /** + * Client capability, not deployment policy: a negotiated client can render + * live answer text and honor `chunk_reset`, so it streams token by token + * regardless of whether thinking or tools are exposed. + */ + const clientAcceptsProtocol = + Boolean(options.requestHeaders) && clientAcceptsAgentStreamProtocol(options.requestHeaders!) + const emitThinking = clientAcceptsProtocol && streamConfig.includeThinking === true + const emitToolCalls = clientAcceptsProtocol && streamConfig.includeToolCalls === true + const maxThinkingChars = DEFAULT_MAX_THINKING_CHARS + + let requestAborted = false + const onRequestAbort = () => { + requestAborted = true + timeoutController.abort() + } + if (options.requestSignal) { + if (options.requestSignal.aborted) { + onRequestAbort() + } else { + options.requestSignal.addEventListener('abort', onRequestAbort, { once: true }) + } + } + + const cleanupRequestAbort = () => { + options.requestSignal?.removeEventListener('abort', onRequestAbort) + } return new ReadableStream({ async start(controller) { @@ -370,6 +536,7 @@ export async function createStreamingResponse( selectedOutputBytes: 0, streamedSelectedOutputKeys: new Set(), } + let thinkingCharsEmitted = 0 const sendChunk = ( blockId: string, @@ -386,12 +553,47 @@ export async function createStreamingResponse( state.selectedOutputBytes = nextSelectedOutputBytes state.streamedSelectedOutputKeys.add(options.selectedOutputKey) } - controller.enqueue(encodeSSE({ blockId, chunk })) + const frame: ChatStreamChunkFrame = { blockId, chunk } + controller.enqueue(encodeSSE(frame)) state.processedOutputs.add(blockId) } + const sendThinking = (blockId: string, text: string) => { + if (!text || thinkingCharsEmitted >= maxThinkingChars) return + const remaining = maxThinkingChars - thinkingCharsEmitted + const forwarded = text.length > remaining ? text.slice(0, remaining) : text + thinkingCharsEmitted += forwarded.length + // Never push thinking into streamedChunks — logs stay answer-text only. + const frame: ChatStreamThinkingFrame = { + blockId, + event: 'thinking', + data: forwarded, + } + controller.enqueue(encodeSSE(frame)) + } + + const sendTool = ( + blockId: string, + phase: 'start' | 'end', + id: string, + name: string, + status?: ToolCallEndStatus + ) => { + const frame: ChatStreamToolFrame = { + blockId, + event: 'tool', + phase, + id, + name, + ...(phase === 'end' && status ? { status } : {}), + } + controller.enqueue(encodeSSE(frame)) + } + /** * Callback for handling streaming execution events. + * Subscribe synchronously before the first await so the executor pump + * can attach sinks before pulling provider chunks. */ const onStreamCallback = async (streamingExec: StreamingExecutionWithBlockId) => { const blockId = streamingExec.execution?.blockId @@ -400,9 +602,67 @@ export async function createStreamingResponse( return } + /** + * Negotiated clients get answer text live from the sink (pending deltas + * stream as the model generates; `chunk_reset` clears an intermediate + * turn). The byte stream then only feeds `streamedChunks` for logs. + * + * Legacy clients stay on the byte stream, which a streaming tool loop + * only writes once the turn is classified — correct for a consumer that + * cannot retract, at the cost of arriving in one piece. + * + * Response-format projections rewrite the bytes, so those blocks keep + * the byte stream as the frame source either way. + */ + const sinkAnswerText = + clientAcceptsProtocol && + Boolean(streamingExec.subscribe) && + streamingExec.clientStreamTransformed !== true + + /** False until the first chunk since block start or since a reset. */ + let emittedSinceReset = false + + const emitAnswerChunk = (text: string) => { + if (!text) return + if (!emittedSinceReset) { + // sendChunk adds the cross-block separator + output bookkeeping. + sendChunk(blockId, text) + emittedSinceReset = true + } else { + const frame: ChatStreamChunkFrame = { blockId, chunk: text } + controller.enqueue(encodeSSE(frame)) + } + } + + let unsubscribe: (() => void) | undefined + if (clientAcceptsProtocol && streamingExec.subscribe) { + unsubscribe = streamingExec.subscribe({ + onEvent: async (event) => { + if (event.type === 'thinking_delta') { + if (emitThinking) sendThinking(blockId, event.text) + } else if (event.type === 'tool_call_start') { + if (emitToolCalls) sendTool(blockId, 'start', event.id, event.name) + } else if (event.type === 'tool_call_end') { + if (emitToolCalls) sendTool(blockId, 'end', event.id, event.name, event.status) + } else if (sinkAnswerText && event.type === 'text_delta') { + if (event.turn !== 'intermediate') { + emitAnswerChunk(event.text) + } + } else if (sinkAnswerText && event.type === 'turn_end') { + if (event.turn === 'intermediate' && emittedSinceReset) { + const frame: ChatStreamChunkResetFrame = { blockId, event: 'chunk_reset' } + controller.enqueue(encodeSSE(frame)) + // Re-arm separator bookkeeping so re-streamed text starts clean. + emittedSinceReset = false + state.processedOutputs.delete(blockId) + } + } + }, + }) + } + const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() - let isFirstChunk = true try { while (true) { @@ -418,22 +678,20 @@ export async function createStreamingResponse( } state.streamedChunks.get(blockId)!.push(textChunk) - if (isFirstChunk) { - sendChunk(blockId, textChunk) - isFirstChunk = false - } else { - controller.enqueue(encodeSSE({ blockId, chunk: textChunk })) + if (!sinkAnswerText) { + emitAnswerChunk(textChunk) } } } catch (error) { logger.error(`[${requestId}] Error reading stream for block ${blockId}:`, error) - controller.enqueue( - encodeSSE({ - event: 'stream_error', - blockId, - error: getErrorMessage(error, 'Stream reading error'), - }) - ) + const frame: ChatStreamStreamErrorFrame = { + event: 'stream_error', + blockId, + error: getErrorMessage(error, 'Stream reading error'), + } + controller.enqueue(encodeSSE(frame)) + } finally { + unsubscribe?.() } } @@ -455,6 +713,18 @@ export async function createStreamingResponse( (descriptor) => descriptor.blockId === blockId ) + /** + * A selected output is streamed here and then skipped in the `final` + * envelope, so this is the reachable path for a deployment that selects + * `toolCalls` or `providerTiming` — sanitizing only the envelope would + * leave the payload flowing through the chunk frame instead. + */ + const sanitizedOutput = isRecordLike(output) + ? sanitizeOutputForEnvelope(output, { + redactToolPayloads: streamConfig.isSecureMode === true, + }) + : output + for (const descriptor of matchingOutputs) { if (state.selectedOutputError) { break @@ -477,7 +747,11 @@ export async function createStreamingResponse( ), } const materializationContext = buildMaterializationContext(extractionContext) - const outputValue = await extractOutputValue(output, descriptor.path, extractionContext) + const outputValue = await extractOutputValue( + sanitizedOutput, + descriptor.path, + extractionContext + ) if (outputValue !== undefined) { const materializedOutput = await materializeInlineExecutionValue( @@ -521,13 +795,12 @@ export async function createStreamingResponse( }) const errorMessage = getSelectedOutputErrorMessage(error) state.selectedOutputError ??= errorMessage - controller.enqueue( - encodeSSE({ - event: 'error', - blockId, - error: errorMessage, - }) - ) + const frame: ChatStreamErrorFrame = { + event: 'error', + blockId, + error: errorMessage, + } + controller.enqueue(encodeSSE(frame)) break } } @@ -555,7 +828,8 @@ export async function createStreamingResponse( if ( result.status === 'cancelled' && timeoutController.isTimedOut() && - timeoutController.timeoutMs + timeoutController.timeoutMs && + !requestAborted ) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) logger.info(`[${requestId}] Streaming execution timed out`, { @@ -564,7 +838,17 @@ export async function createStreamingResponse( if (result._streamingMetadata?.loggingSession) { await result._streamingMetadata.loggingSession.markAsFailed(timeoutErrorMessage) } - controller.enqueue(encodeSSE({ event: 'error', error: timeoutErrorMessage })) + const frame: ChatStreamErrorFrame = { event: 'error', error: timeoutErrorMessage } + controller.enqueue(encodeSSE(frame)) + } else if (result.status === 'cancelled' && requestAborted) { + logger.info(`[${requestId}] Streaming execution aborted by client disconnect`) + if (result._streamingMetadata?.loggingSession) { + // LoggingSession has no cancelled status; match workflow execute route wording. + await result._streamingMetadata.loggingSession.markAsFailed('Client cancelled request') + } + // No `final` after abort; clients that already disconnected ignore these. + const frame: ChatStreamErrorFrame = { event: 'error', error: 'Client cancelled request' } + controller.enqueue(encodeSSE(frame)) } else { await completeLoggingSession(result) @@ -588,21 +872,22 @@ export async function createStreamingResponse( fileKeys: result.metadata?.fileKeys ?? options.fileKeys, allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope, userId: options.userId, + redactToolPayloads: streamConfig.isSecureMode === true, } ) - controller.enqueue( - encodeSSE({ - event: 'final', - data: { - ...minimalResult, - ...(result.status === 'paused' && { status: 'paused' }), - }, - }) - ) + const frame: ChatStreamFinalFrame = { + event: 'final', + data: { + ...minimalResult, + ...(result.status === 'paused' && { status: 'paused' }), + }, + } + controller.enqueue(encodeSSE(frame)) } } + // Terminal marker: always follows success `final` or a single terminal `error`. controller.enqueue(encodeSSE('[DONE]')) if (executionId) { @@ -616,7 +901,10 @@ export async function createStreamingResponse( streamConfig.selectedOutputs?.length && isExecutionResourceLimitError(error) ? SELECTED_OUTPUT_TOO_LARGE_MESSAGE : getErrorMessage(error, 'Stream processing error') - controller.enqueue(encodeSSE({ event: 'error', error: errorMessage })) + const frame: ChatStreamErrorFrame = { event: 'error', error: errorMessage } + controller.enqueue(encodeSSE(frame)) + // Same terminal rule as timeout/abort: one error, then [DONE], never `final`. + controller.enqueue(encodeSSE('[DONE]')) if (executionId) { await cleanupExecutionBase64Cache(executionId) @@ -624,12 +912,15 @@ export async function createStreamingResponse( controller.close() } finally { + cleanupRequestAbort() timeoutController.cleanup() } }, async cancel(reason) { logger.info(`[${requestId}] Streaming response cancelled`, { reason }) + requestAborted = true timeoutController.abort() + cleanupRequestAbort() timeoutController.cleanup() if (executionId) { try { diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index 81c12739b40..6afc4f66554 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -164,6 +164,21 @@ export async function checkWorkspaceAccess( return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws, permission } } +/** + * Returns `provided` when it was resolved for this exact workspace, otherwise + * resolves fresh. The id match is what keeps a caller from authorizing against + * another workspace's cached access - every access-reuse path must go through + * this rather than hand-rolling the comparison. + */ +export async function resolveWorkspaceAccess( + workspaceId: string, + userId: string, + provided?: WorkspaceAccess +): Promise { + if (provided && provided.workspace?.id === workspaceId) return provided + return checkWorkspaceAccess(workspaceId, userId) +} + /** * Thrown when a user attempts to access a workspace they don't have access to, * or that doesn't exist / has been archived. Carries `statusCode = 403` so the diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts b/apps/sim/lib/workspaces/sharing.test.ts similarity index 96% rename from apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts rename to apps/sim/lib/workspaces/sharing.test.ts index c762ac9f0fc..1c202684093 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts +++ b/apps/sim/lib/workspaces/sharing.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { partitionSettledFailures, resolveAddEmail } from './sharing' +import { partitionSettledFailures, resolveAddEmail } from '@/lib/workspaces/sharing' describe('resolveAddEmail', () => { const ctx = { diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts b/apps/sim/lib/workspaces/sharing.ts similarity index 88% rename from apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts rename to apps/sim/lib/workspaces/sharing.ts index f315ba0eedf..a42eed3bd21 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts +++ b/apps/sim/lib/workspaces/sharing.ts @@ -1,14 +1,15 @@ export interface ResolveAddEmailContext { /** Lowercased email -> workspace userId for every workspace member. */ workspaceUserIdByEmail: Map - /** Lowercased emails that already have access to the credential. */ + /** Lowercased emails that already have access to the resource. */ existingMemberEmails: Set } export type ResolveAddEmailResult = { userId: string } | { error: string } /** - * Decide whether a format-valid email can be added to a credential: it must + * Decide whether a format-valid email can be added to a shared resource + * (credential, skill): it must * belong to a workspace member and not already have access. Matching is * case-insensitive (the context map/set are keyed by lowercased email) while * error messages echo the email as the user typed it. Returns the resolved diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index ebcb52775ea..3c458571a62 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -130,6 +130,7 @@ const nextConfig: NextConfig = { 'isolated-vm', '@e2b/code-interpreter', 'e2b', + '@daytonaio/sdk', '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', ], diff --git a/apps/sim/package.json b/apps/sim/package.json index 1b9c251ff56..61079933f1b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -36,7 +36,7 @@ "dependencies": { "@1password/sdk": "0.3.1", "@a2a-js/sdk": "1.0.0-alpha.0", - "@anthropic-ai/sdk": "0.71.2", + "@anthropic-ai/sdk": "0.114.0", "@aws-sdk/client-appconfigdata": "3.1032.0", "@aws-sdk/client-athena": "3.1032.0", "@aws-sdk/client-bedrock-runtime": "3.1032.0", @@ -65,6 +65,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", + "@daytonaio/sdk": "0.197.0", "@e2b/code-interpreter": "^2.0.0", "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", @@ -256,13 +257,6 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.1.0" }, - "trustedDependencies": [ - "canvas", - "better-sqlite3", - "ffmpeg-static", - "isolated-vm", - "sharp" - ], "overrides": { "next": "16.2.11", "@next/env": "16.2.11", diff --git a/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts new file mode 100644 index 00000000000..b16d8107bc9 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + * + * Fixture gate: Anthropic stream fixtures parse and match expected assembled + * thinking/text/tool/signature content. No provider adapters are exercised yet. + */ +import { describe, expect, it } from 'vitest' +import { + anthropicRedactedThinkingAssembledContent, + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, + anthropicThinkingTextToolAssembledContent, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' + +type StreamEvent = { + type: string + index?: number + delta?: { + type: string + thinking?: string + text?: string + signature?: string + partial_json?: string + } + content_block?: { + type: string + thinking?: string + text?: string + data?: string + id?: string + name?: string + input?: unknown + signature?: string + } +} + +function assembleAnthropicContentFromStream(events: readonly StreamEvent[]) { + const blocks: Array> = [] + + for (const event of events) { + if (event.type === 'content_block_start' && event.content_block) { + const block = event.content_block + if (block.type === 'thinking') { + blocks.push({ type: 'thinking', thinking: block.thinking ?? '', signature: '' }) + } else if (block.type === 'redacted_thinking') { + blocks.push({ type: 'redacted_thinking', data: block.data ?? '' }) + } else if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text ?? '' }) + } else if (block.type === 'tool_use') { + blocks.push({ + type: 'tool_use', + id: block.id, + name: block.name, + inputJson: '', + }) + } + continue + } + + if (event.type !== 'content_block_delta' || event.index === undefined || !event.delta) { + continue + } + + const target = blocks[event.index] + if (!target) continue + + const delta = event.delta + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + target.thinking = `${target.thinking ?? ''}${delta.thinking}` + } else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') { + target.signature = delta.signature + } else if (delta.type === 'text_delta' && typeof delta.text === 'string') { + target.text = `${target.text ?? ''}${delta.text}` + } else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') { + target.inputJson = `${target.inputJson ?? ''}${delta.partial_json}` + } + } + + return blocks.map((block) => { + if (block.type === 'tool_use') { + const inputJson = typeof block.inputJson === 'string' ? block.inputJson : '{}' + return { + type: 'tool_use', + id: block.id, + name: block.name, + input: JSON.parse(inputJson || '{}'), + } + } + if (block.type === 'thinking') { + return { + type: 'thinking', + thinking: block.thinking, + signature: block.signature, + } + } + return block + }) +} + +function extractTextDeltas(events: readonly StreamEvent[]): string { + return events + .filter( + (e) => + e.type === 'content_block_delta' && + e.delta?.type === 'text_delta' && + typeof e.delta.text === 'string' + ) + .map((e) => e.delta!.text!) + .join('') +} + +function extractThinkingDeltas(events: readonly StreamEvent[]): string { + return events + .filter( + (e) => + e.type === 'content_block_delta' && + e.delta?.type === 'thinking_delta' && + typeof e.delta.thinking === 'string' + ) + .map((e) => e.delta!.thinking!) + .join('') +} + +function assertValidStreamSequence(events: readonly StreamEvent[]) { + expect(events[0]?.type).toBe('message_start') + expect(events[events.length - 1]?.type).toBe('message_stop') + + const open = new Set() + for (const event of events) { + if (event.type === 'content_block_start' && event.index !== undefined) { + expect(open.has(event.index)).toBe(false) + open.add(event.index) + } + if (event.type === 'content_block_stop' && event.index !== undefined) { + expect(open.has(event.index)).toBe(true) + open.delete(event.index) + } + } + expect(open.size).toBe(0) +} + +describe('Anthropic stream fixtures', () => { + it('parses thinking → text → tool_use stream and preserves signature', () => { + assertValidStreamSequence(anthropicThinkingTextToolStreamEvents) + + expect(extractThinkingDeltas(anthropicThinkingTextToolStreamEvents)).toBe( + anthropicThinkingTextToolExpectedThinking + ) + expect(extractTextDeltas(anthropicThinkingTextToolStreamEvents)).toBe( + anthropicThinkingTextToolExpectedText + ) + + const assembled = assembleAnthropicContentFromStream(anthropicThinkingTextToolStreamEvents) + expect(assembled).toEqual([...anthropicThinkingTextToolAssembledContent]) + + const thinkingBlock = assembled.find((b) => b.type === 'thinking') as { + signature?: string + } + expect(thinkingBlock?.signature).toMatch(/^EpAB/) + }) + + it('parses redacted_thinking + signed thinking + text and matches trace mapping', () => { + assertValidStreamSequence(anthropicRedactedThinkingStreamEvents) + + expect(extractTextDeltas(anthropicRedactedThinkingStreamEvents)).toBe( + anthropicRedactedThinkingExpectedText + ) + + const assembled = assembleAnthropicContentFromStream(anthropicRedactedThinkingStreamEvents) + expect(assembled).toEqual([...anthropicRedactedThinkingAssembledContent]) + + // Mirrors enrichLastModelSegmentFromAnthropicResponse: redacted → "[redacted]" + const traceThinking = assembled + .filter((b) => b.type === 'thinking' || b.type === 'redacted_thinking') + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + expect(traceThinking).toBe(anthropicRedactedThinkingExpectedTraceThinking) + }) + + it('documents that live stream today would only surface text_delta bytes', () => { + // Baseline behavior of createReadableStreamFromAnthropicStream: only text_delta + // is enqueued. This test locks the fixture expectation for later adapter work. + const textOnlyFromStream = extractTextDeltas(anthropicThinkingTextToolStreamEvents) + const thinkingFromStream = extractThinkingDeltas(anthropicThinkingTextToolStreamEvents) + + expect(textOnlyFromStream).toBe(anthropicThinkingTextToolExpectedText) + expect(thinkingFromStream.length).toBeGreaterThan(0) + expect(textOnlyFromStream).not.toContain('I should check the weather') + }) +}) diff --git a/apps/sim/providers/__fixtures__/anthropic/index.ts b/apps/sim/providers/__fixtures__/anthropic/index.ts new file mode 100644 index 00000000000..222e7af8c71 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/index.ts @@ -0,0 +1,17 @@ +/** + * Re-exports Anthropic stream fixtures used by agent-stream-events work. + * Keep fixture data in dedicated modules so adapters can import without pulling tests. + */ + +export { + anthropicRedactedThinkingAssembledContent, + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, +} from '@/providers/__fixtures__/anthropic/redacted-thinking-signature' +export { + anthropicThinkingTextToolAssembledContent, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic/thinking-text-tool' diff --git a/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts new file mode 100644 index 00000000000..7e70d0a44e5 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts @@ -0,0 +1,98 @@ +/** + * Anthropic stream + assembled message covering redacted_thinking blocks. + * + * When Anthropic redacts thinking, the stream emits a redacted_thinking content + * block (opaque `data`) instead of thinking_delta text. Multi-turn tool loops + * must round-trip that block (and any adjacent signed thinking blocks) back + * into subsequent Messages API requests unchanged. + */ + +export const anthropicRedactedThinkingStreamEvents = [ + { + type: 'message_start', + message: { + id: 'msg_fixture_redacted_thinking', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 20, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'redacted_thinking', + data: 'fixture-redacted-thinking-opaque-blob-001', + }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { + type: 'thinking', + thinking: '', + }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'thinking_delta', thinking: 'Visible follow-up reasoning after redaction.' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { + type: 'signature_delta', + signature: 'EpABCkYICBgCKkDfixture-visible-thinking-signature-def456', + }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'text_delta', text: 'Here is the answer after redacted thinking.' }, + }, + { type: 'content_block_stop', index: 2 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 64 }, + }, + { type: 'message_stop' }, +] as const + +/** + * Assembled content that must be preserved when appending the assistant turn + * to Anthropic history (see anthropic/core.ts thinking/redacted_thinking filters). + */ +export const anthropicRedactedThinkingAssembledContent = [ + { + type: 'redacted_thinking', + data: 'fixture-redacted-thinking-opaque-blob-001', + }, + { + type: 'thinking', + thinking: 'Visible follow-up reasoning after redaction.', + signature: 'EpABCkYICBgCKkDfixture-visible-thinking-signature-def456', + }, + { + type: 'text', + text: 'Here is the answer after redacted thinking.', + }, +] as const + +/** What enrichLastModelSegmentFromAnthropicResponse maps redacted blocks to today. */ +export const anthropicRedactedThinkingExpectedTraceThinking = + '[redacted]\n\nVisible follow-up reasoning after redaction.' + +export const anthropicRedactedThinkingExpectedText = 'Here is the answer after redacted thinking.' diff --git a/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts new file mode 100644 index 00000000000..8ac06d00a63 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts @@ -0,0 +1,118 @@ +/** + * Anthropic Messages API SSE-style stream events for a single assistant turn that: + * 1. Streams extended thinking (thinking_delta + signature_delta) + * 2. Streams answer text (text_delta) + * 3. Streams a tool_use block (input_json_delta) + * + * Shapes mirror Anthropic RawMessageStreamEvent fields used by + * `createReadableStreamFromAnthropicStream`. The adapter emits thinking_delta + + * text_delta AgentStreamEvents; tool_use deltas are ignored until the streaming + * tool loop. + */ + +export const anthropicThinkingTextToolStreamEvents = [ + { + type: 'message_start', + message: { + id: 'msg_fixture_thinking_text_tool', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 42, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '', signature: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'I should check the weather before answering. ' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Calling get_weather for SF.' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { + type: 'signature_delta', + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'Let me check the weather in San Francisco.' }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '{"city":' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '"San Francisco"}' }, + }, + { type: 'content_block_stop', index: 2 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use', stop_sequence: null }, + usage: { output_tokens: 128 }, + }, + { type: 'message_stop' }, +] as const + +/** + * Expected assembled assistant Message.content after draining the stream above. + * Used for history round-trip tests (thinking block must keep its signature). + */ +export const anthropicThinkingTextToolAssembledContent = [ + { + type: 'thinking', + thinking: 'I should check the weather before answering. Calling get_weather for SF.', + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + { + type: 'text', + text: 'Let me check the weather in San Francisco.', + }, + { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: { city: 'San Francisco' }, + }, +] as const + +/** Concatenated thinking text (what traces should store as thinkingContent). */ +export const anthropicThinkingTextToolExpectedThinking = + 'I should check the weather before answering. Calling get_weather for SF.' + +/** Concatenated answer text (what output.content / live stream should contain today). */ +export const anthropicThinkingTextToolExpectedText = 'Let me check the weather in San Francisco.' diff --git a/apps/sim/providers/__fixtures__/openai-compat/index.ts b/apps/sim/providers/__fixtures__/openai-compat/index.ts new file mode 100644 index 00000000000..d13cf94acbf --- /dev/null +++ b/apps/sim/providers/__fixtures__/openai-compat/index.ts @@ -0,0 +1,69 @@ +/** + * OpenAI-compat stream fixtures — capability-honest reasoning deltas. + */ +export const openaiCompatReasoningAndTextChunks = [ + { + choices: [ + { + delta: { + reasoning_content: 'I should compute carefully. ', + }, + }, + ], + }, + { + choices: [ + { + delta: { + reasoning_content: 'Answer is 4.', + content: '2+2=', + }, + }, + ], + }, + { + choices: [{ delta: { content: '4' } }], + }, + // Usage arrives on a trailing chunk with empty choices (stream_options.include_usage). + { + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }, +] as const + +export const openaiCompatTextOnlyChunks = [ + { + choices: [{ delta: { content: 'Hello' } }], + }, + { + choices: [{ delta: { content: ' world' } }], + }, + // Usage arrives on a trailing chunk with empty choices (stream_options.include_usage). + { + choices: [], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }, +] as const + +export const openaiCompatToolCallStartChunks = [ + { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: 'call_abc', function: { name: 'http_request', arguments: '' } }, + ], + }, + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '{"url":' } }], + }, + }, + ], + }, +] as const diff --git a/apps/sim/providers/agent-events-smokes.test.ts b/apps/sim/providers/agent-events-smokes.test.ts new file mode 100644 index 00000000000..3c7e94891ff --- /dev/null +++ b/apps/sim/providers/agent-events-smokes.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + * + * Agent-events capability smokes: Anthropic thinking+tool fixture, openai-compat thinking, + * and a non-thinking text-only stream — capability-honest expectations. + */ +import { describe, expect, it } from 'vitest' +import { anthropicThinkingTextToolStreamEvents } from '@/providers/__fixtures__/anthropic' +import { + openaiCompatReasoningAndTextChunks, + openaiCompatTextOnlyChunks, +} from '@/providers/__fixtures__/openai-compat' +import { createReadableStreamFromAnthropicStream } from '@/providers/anthropic/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('agent-events provider smokes', () => { + it('Anthropic fixture emits thinking then text (tools ignored on simple util)', async () => { + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicThinkingTextToolStreamEvents as any + })() + ) + const events = await collectEvents(stream) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(true) + expect(events.some((e) => e.type === 'text_delta')).toBe(true) + // Simple Anthropic util does not emit tool_call_* (loop does). + expect(events.some((e) => e.type === 'tool_call_start')).toBe(false) + }) + + it('openai-compat reasoning model emits thinking_delta', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatReasoningAndTextChunks as any + })(), + { providerName: 'DeepSeek' } + ) + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').length).toBeGreaterThan(0) + expect(events.some((e) => e.type === 'text_delta')).toBe(true) + }) + + it('openai-compat non-thinking model stays text-only', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'OpenAI' } + ) + const events = await collectEvents(stream) + expect(events.every((e) => e.type === 'text_delta')).toBe(true) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + }) +}) diff --git a/apps/sim/providers/anthropic/core.request.test.ts b/apps/sim/providers/anthropic/core.request.test.ts new file mode 100644 index 00000000000..afe13ce5ff8 --- /dev/null +++ b/apps/sim/providers/anthropic/core.request.test.ts @@ -0,0 +1,250 @@ +/** + * @vitest-environment node + */ +import type Anthropic from '@anthropic-ai/sdk' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +describe('executeAnthropicProviderRequest request identity and usage', () => { + beforeEach(() => { + mockExecuteTool.mockReset() + }) + + it('keeps registry identity while sending the resolved wire model and aggregating cache usage', async () => { + const create = vi.fn().mockResolvedValue({ + id: 'msg-test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + cache_creation: null, + output_tokens: 40, + }, + }) + + const result = (await executeAnthropicProviderRequest( + { + model: 'azure-anthropic/claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + messages: [{ role: 'system', content: 'Remain concise.' }], + }, + { + providerId: 'azure-anthropic', + providerLabel: 'Azure Anthropic', + resolveWireModel: () => 'claude-sonnet-4-5', + createClient: () => ({ messages: { create } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + )) as ProviderResponse + + expect(create.mock.calls[0][0]).toMatchObject({ + model: 'claude-sonnet-4-5', + // System is always block-shaped so cache_control has somewhere to live. + system: [{ type: 'text', text: 'Remain concise.' }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], + }) + expect(result.model).toBe('azure-anthropic/claude-sonnet-4-5') + expect(result.tokens).toEqual({ + input: 10, + output: 40, + total: 100, + cacheRead: 20, + cacheWrite: 30, + }) + expect(result.cost).toMatchObject({ + input: 0.0001485, + output: 0.0006, + total: 0.0007485, + }) + expect(result.timing?.timeSegments?.[0]).toMatchObject({ + provider: 'azure-anthropic', + tokens: { + input: 10, + output: 40, + total: 100, + cacheRead: 20, + cacheWrite: 30, + }, + }) + }) + + it('applies tool post-processing consistently in non-streaming tool loops', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { posted: true } }) + const create = vi + .fn() + .mockResolvedValueOnce({ + id: 'msg-tool', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'tool_use', id: 'tool-1', name: 'publish', input: {} }], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + .mockResolvedValueOnce({ + id: 'msg-answer', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Published' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + + await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + messages: [{ role: 'user', content: 'Publish this' }], + tools: [ + { + id: 'publish', + name: 'publish', + description: 'Publish a post', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { create } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'publish', + expect.any(Object), + expect.not.objectContaining({ skipPostProcess: true }) + ) + }) +}) + +describe('executeAnthropicProviderRequest prompt caching', () => { + function answerOnce() { + return vi.fn().mockResolvedValue({ + id: 'msg-cache', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + } + + const tools = [ + { + id: 'first', + name: 'first', + description: 'First tool', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + { + id: 'second', + name: 'second', + description: 'Second tool', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ] + + async function sendRequest(overrides: Partial) { + const create = answerOnce() + await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + systemPrompt: 'Remain concise.', + messages: [{ role: 'user', content: 'Hello' }], + ...overrides, + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { create } }) as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } + ) + return create.mock.calls[0][0] as Anthropic.Messages.MessageCreateParams + } + + beforeEach(() => { + mockExecuteTool.mockReset() + }) + + it('breaks the cache after the last tool and the last system block when enabled', async () => { + const payload = await sendRequest({ promptCaching: true, tools }) + + expect(payload.system).toEqual([ + { type: 'text', text: 'Remain concise.', cache_control: { type: 'ephemeral' } }, + ]) + expect(payload.tools?.map((tool) => tool.cache_control)).toEqual([ + undefined, + { type: 'ephemeral' }, + ]) + }) + + it('sends no breakpoints when caching is off', async () => { + const payload = await sendRequest({ tools }) + + expect(payload.system).toEqual([{ type: 'text', text: 'Remain concise.' }]) + expect(payload.tools?.some((tool) => tool.cache_control)).toBe(false) + }) + + it('appends schema instructions as a block and caches only the last one', async () => { + const payload = await sendRequest({ + // Opus 4.1 lacks native structured outputs, so the schema is injected + // into the system prompt — the path that used to string-concatenate. + model: 'claude-opus-4-1', + promptCaching: true, + responseFormat: { name: 'answer', schema: { type: 'object', properties: {} } }, + }) + + const blocks = payload.system as Anthropic.Messages.TextBlockParam[] + expect(blocks).toHaveLength(2) + expect(blocks[0]).toEqual({ type: 'text', text: 'Remain concise.' }) + expect(blocks[1].text).toContain('answer') + expect(blocks[1].cache_control).toEqual({ type: 'ephemeral' }) + }) + + it('omits the system field entirely when there is no system text', async () => { + const payload = await sendRequest({ promptCaching: true, systemPrompt: undefined }) + + expect(payload.system).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/anthropic/core.streaming.test.ts b/apps/sim/providers/anthropic/core.streaming.test.ts new file mode 100644 index 00000000000..2424f53be66 --- /dev/null +++ b/apps/sim/providers/anthropic/core.streaming.test.ts @@ -0,0 +1,220 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' +import type { AgentStreamEvent } from '@/providers/stream-events' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +function message(content: unknown[], stopReason: string) { + return { + id: `msg-${stopReason}`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + } +} + +function stream(events: unknown[], finalMessage: ReturnType) { + return { + async *[Symbol.asyncIterator]() { + yield* events + }, + finalMessage: async () => finalMessage, + } +} + +async function collectEvents(result: StreamingExecution): Promise { + const events: AgentStreamEvent[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + +describe('executeAnthropicProviderRequest live tool streaming', () => { + it.each([ + ['anthropic', 'Anthropic'], + ['azure-anthropic', 'Azure Anthropic'], + ] as const)( + 'uses the live loop for %s streaming tool requests without a caller flag', + async (providerId, providerLabel) => { + const model = + providerId === 'azure-anthropic' ? 'azure-anthropic/claude-sonnet-4-5' : 'claude-sonnet-4-5' + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const toolMessage = message( + [{ type: 'tool_use', id: 'tool-1', name: 'lookup', input: {} }], + 'tool_use' + ) + const answerMessage = message([{ type: 'text', text: 'settled answer' }], 'end_turn') + const createStream = vi + .fn() + .mockReturnValueOnce( + stream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'tool-1', + name: 'lookup', + input: {}, + }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_stop' }, + ], + toolMessage + ) + ) + .mockReturnValueOnce( + stream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'settled answer' }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_stop' }, + ], + answerMessage + ) + ) + + const result = (await executeAnthropicProviderRequest( + { + model, + apiKey: 'test-key', + stream: true, + maxTokens: 1024, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId, + providerLabel, + ...(providerId === 'azure-anthropic' + ? { resolveWireModel: () => 'claude-sonnet-4-5' } + : {}), + createClient: () => ({ messages: { stream: createStream } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + )) as StreamingExecution + + const events = await collectEvents(result) + + expect(createStream).toHaveBeenCalledTimes(2) + expect(createStream.mock.calls[0][0].model).toBe('claude-sonnet-4-5') + expect(events).toContainEqual({ + type: 'text_delta', + text: 'settled answer', + turn: 'pending', + }) + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(result.execution.output.content).toBe('settled answer') + expect(result.execution.output.model).toBe(model) + expect( + result.execution.output.providerTiming?.timeSegments + ?.filter((segment) => segment.type === 'model') + .map((segment) => segment.provider) + ).toEqual([providerId, providerId]) + } + ) +}) + +/** + * Streaming is the common path, and breakpoints are placed on the shared + * payload before the loop is handed it. A regression that moved placement + * below the streaming branch would silently stop caching while the + * non-streaming payload tests still passed. + */ +describe('executeAnthropicProviderRequest streaming prompt caching', () => { + it('carries cache breakpoints into every turn of the live tool loop', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const createStream = vi + .fn() + .mockReturnValueOnce( + stream( + [{ type: 'message_stop' }], + message([{ type: 'tool_use', id: 'tool-1', name: 'lookup', input: {} }], 'tool_use') + ) + ) + .mockReturnValueOnce( + stream([{ type: 'message_stop' }], message([{ type: 'text', text: 'done' }], 'end_turn')) + ) + + const result = (await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + stream: true, + maxTokens: 1024, + promptCaching: true, + systemPrompt: 'Remain concise.', + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { stream: createStream } }) as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } + )) as StreamingExecution + + await collectEvents(result) + + expect(createStream).toHaveBeenCalledTimes(2) + for (const call of createStream.mock.calls) { + const payload = call[0] + expect(payload.system).toEqual([ + { type: 'text', text: 'Remain concise.', cache_control: { type: 'ephemeral' } }, + ]) + expect(payload.tools.at(-1).cache_control).toEqual({ type: 'ephemeral' }) + } + }) +}) diff --git a/apps/sim/providers/anthropic/core.thinking.test.ts b/apps/sim/providers/anthropic/core.thinking.test.ts new file mode 100644 index 00000000000..8fcad2e438d --- /dev/null +++ b/apps/sim/providers/anthropic/core.thinking.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + * + * Anthropic thinking config: the summarized-display opt-in is requested only + * on agent-events runs and only for models whose registry marks summarized + * streaming (the omitted-display Claude generations). Legacy runs keep the + * exact pre-agent-events request shape. + */ +import { describe, expect, it } from 'vitest' +import { buildThinkingConfig } from '@/providers/anthropic/core' + +describe('buildThinkingConfig', () => { + it('requests summarized display for omitted-display models on agent-events runs', () => { + for (const model of [ + 'claude-fable-5', + 'claude-sonnet-5', + 'claude-opus-5', + 'claude-opus-4-8', + 'claude-opus-4-7', + ]) { + const config = buildThinkingConfig(model, 'high', true) + expect(config?.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(config?.outputConfig).toEqual({ effort: 'high' }) + } + }) + + it('never adds display on legacy runs (no agent events)', () => { + for (const model of [ + 'claude-fable-5', + 'claude-sonnet-5', + 'claude-opus-5', + 'claude-opus-4-8', + 'claude-opus-4-7', + ]) { + const config = buildThinkingConfig(model, 'high', false) + expect(config?.thinking).toEqual({ type: 'adaptive' }) + } + }) + + it('requests summarized display for adaptive models marked as summary-streamed', () => { + for (const model of ['claude-opus-4-6', 'claude-sonnet-4-6']) { + const config = buildThinkingConfig(model, 'high', true) + expect(config?.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + } + }) + + it('keeps budget-token models on the extended thinking path', () => { + const config = buildThinkingConfig('claude-sonnet-4-5', 'high', true) + expect(config?.thinking).toMatchObject({ type: 'enabled' }) + expect(config?.thinking).not.toHaveProperty('display') + }) + + it('returns null for unknown levels and non-thinking models', () => { + expect(buildThinkingConfig('claude-fable-5', 'not-a-level', true)).toBeNull() + expect(buildThinkingConfig('gpt-4o', 'high', true)).toBeNull() + }) +}) diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 27d84febb1d..88e5d8d71a6 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,13 +3,21 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { BlockTokens, IterationToolCall, StreamingExecution } from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { convertAnthropicRequestHistory } from '@/providers/anthropic/request-history' +import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' import { checkForForcedToolUsage, createReadableStreamFromAnthropicStream, } from '@/providers/anthropic/utils' -import { buildAnthropicMessageContent } from '@/providers/attachments' import { getMaxOutputTokensForModel, getThinkingCapability, @@ -17,16 +25,12 @@ import { supportsTemperature, } from '@/providers/models' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError } from '@/providers/streaming-tool-loop-shared' import { adaptAnthropicToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' -import { - calculateCost, - prepareToolExecution, - prepareToolsWithUsageControl, - sumToolCosts, -} from '@/providers/utils' +import { prepareToolExecution, prepareToolsWithUsageControl } from '@/providers/utils' import { executeTool } from '@/tools' /** @@ -38,19 +42,53 @@ export interface AnthropicProviderConfig { /** Human-readable label for logging */ providerLabel: string /** Factory function to create the Anthropic client */ - createClient: (apiKey: string, useNativeStructuredOutputs: boolean) => Anthropic + createClient: (apiKey: string) => Anthropic + /** Resolves the provider-specific model or deployment name sent on the wire. */ + resolveWireModel?: (request: ProviderRequest) => string /** Logger instance */ logger: Logger } +type AnthropicPayload = Anthropic.Messages.MessageStreamParams + +/** Anthropic's only cache type; the default 5-minute TTL suits agent reuse. */ +const EPHEMERAL_CACHE: Anthropic.Messages.CacheControlEphemeral = { type: 'ephemeral' } + +/** + * Builds the `system` field as text blocks. + * + * Always an array, never a bare string, so the shape does not depend on whether + * prompt caching is on and `cache_control` always has a block to attach to. + * Returns `undefined` when there is no system text so the field is omitted. + */ +function buildSystemBlocks( + texts: string[], + cacheLastBlock: boolean +): Anthropic.Messages.TextBlockParam[] | undefined { + const blocks: Anthropic.Messages.TextBlockParam[] = texts + .filter((text) => text.trim().length > 0) + .map((text) => ({ type: 'text', text })) + + if (blocks.length === 0) return undefined + + if (cacheLastBlock) { + blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: EPHEMERAL_CACHE } + } + return blocks +} + /** - * Custom payload type extending the SDK's base message creation params. - * Adds fields not yet in the SDK: adaptive thinking, output_format, output_config. + * Marks the final tool definition, caching every tool ahead of it. + * + * Typed on `ToolUnion` to match the payload field: every member of that union, + * including the server-side tools, accepts `cache_control`. */ -interface AnthropicPayload extends Omit { - thinking?: Anthropic.Messages.ThinkingConfigParam | { type: 'adaptive' } - output_format?: { type: 'json_schema'; schema: Record } - output_config?: { effort: string } +function withCacheBreakpointOnLast( + tools: Anthropic.Messages.ToolUnion[] +): Anthropic.Messages.ToolUnion[] { + const marked = [...tools] + marked[marked.length - 1] = { ...marked[marked.length - 1], cache_control: EPHEMERAL_CACHE } + return marked } /** @@ -92,7 +130,7 @@ const ANTHROPIC_THINKING_OUTPUT_HEADROOM = 4096 * Checks if a model supports adaptive thinking (thinking.type: "adaptive"). * Fable 5 supports ONLY adaptive thinking (always on; type: "disabled" is rejected). * Sonnet 5 supports ONLY adaptive thinking (manual budget_tokens returns a 400 error). - * Opus 4.8 and Opus 4.7 support ONLY adaptive thinking (no extended thinking / budget_tokens). + * Opus 5, Opus 4.8, and Opus 4.7 support ONLY adaptive thinking (no extended thinking / budget_tokens). * Opus 4.6 and Sonnet 4.6 support both extended and adaptive thinking — use adaptive. * Opus 4.5 supports effort but NOT adaptive thinking — it uses budget_tokens with type: "enabled". */ @@ -101,6 +139,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { return ( normalizedModel.includes('fable-5') || normalizedModel.includes('sonnet-5') || + normalizedModel.includes('opus-5') || normalizedModel.includes('opus-4-8') || normalizedModel.includes('opus-4.8') || normalizedModel.includes('opus-4-7') || @@ -115,18 +154,25 @@ function supportsAdaptiveThinking(modelId: string): boolean { /** * Builds the thinking configuration for the Anthropic API based on model capabilities and level. * - * - Fable 5, Sonnet 5, Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) + * - Fable 5, Sonnet 5, Opus 5, Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) * - Opus 4.6, Sonnet 4.6: Uses adaptive thinking with effort parameter * - Other models: Uses budget_tokens-based extended thinking * + * The newest Claude generations default `thinking.display` to `omitted` + * (empty thinking blocks, no thinking deltas). Their registry entries mark + * `capabilities.thinking.streamed: 'summary'`, and for those models Sim opts + * back in with `display: 'summarized'` — but only on agent-events runs, so + * legacy runs keep the exact pre-agent-events request shape. + * * Returns both the thinking config and optional output_config for adaptive thinking. */ -function buildThinkingConfig( +export function buildThinkingConfig( modelId: string, - thinkingLevel: string + thinkingLevel: string, + agentEvents: boolean ): { - thinking: { type: 'enabled'; budget_tokens: number } | { type: 'adaptive' } - outputConfig?: { effort: string } + thinking: Anthropic.Messages.ThinkingConfigParam + outputConfig?: Anthropic.Messages.OutputConfig } | null { const capability = getThinkingCapability(modelId) if (!capability || !capability.levels.includes(thinkingLevel)) { @@ -135,9 +181,14 @@ function buildThinkingConfig( // Models with effort support use adaptive thinking if (supportsAdaptiveThinking(modelId)) { + const requestSummarizedDisplay = agentEvents && capability.streamed === 'summary' return { - thinking: { type: 'adaptive' }, - outputConfig: { effort: thinkingLevel }, + thinking: { + type: 'adaptive', + ...(requestSummarizedDisplay ? { display: 'summarized' as const } : {}), + }, + // Levels are validated against the model's capability list above. + outputConfig: { effort: thinkingLevel as Anthropic.Messages.OutputConfig['effort'] }, } } @@ -205,61 +256,22 @@ export async function executeAnthropicProviderRequest( request.responseFormat && supportsNativeStructuredOutputs(modelId) ) - const anthropic = config.createClient(request.apiKey, useNativeStructuredOutputs) - - const messages: Anthropic.Messages.MessageParam[] = [] - let systemPrompt = request.systemPrompt || '' - - if (request.context) { - messages.push({ - role: 'user', - content: request.context, - }) - } - - if (request.messages) { - request.messages.forEach((msg) => { - if (msg.role === 'function') { - messages.push({ - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: msg.name || '', - content: msg.content || undefined, - }, - ], - }) - } else if (msg.function_call) { - const toolUseId = `${msg.function_call.name}-${Date.now()}` - messages.push({ - role: 'assistant', - content: [ - { - type: 'tool_use', - id: toolUseId, - name: msg.function_call.name, - input: JSON.parse(msg.function_call.arguments), - }, - ], - }) - } else { - const content = buildAnthropicMessageContent(msg.content, msg.files, config.providerId) - messages.push({ - role: msg.role === 'assistant' ? 'assistant' : 'user', - // double-cast-allowed: shared attachment builder returns Anthropic-compatible content blocks but avoids importing SDK-only union types - content: content as unknown as Anthropic.Messages.ContentBlockParam[], - }) - } - }) - } + const anthropic = config.createClient(request.apiKey) + const wireModel = config.resolveWireModel?.(request) ?? modelId + const convertedHistory = convertAnthropicRequestHistory({ + messages: request.messages, + systemPrompt: request.systemPrompt, + context: request.context, + providerId, + }) + const messages = convertedHistory.messages + const systemPrompt = convertedHistory.systemPrompt if (messages.length === 0) { messages.push({ role: 'user', - content: [{ type: 'text', text: systemPrompt || 'Hello' }], + content: [{ type: 'text', text: 'Hello' }], }) - systemPrompt = '' } let anthropicTools: Anthropic.Messages.Tool[] | undefined = request.tools?.length @@ -270,44 +282,41 @@ export async function executeAnthropicProviderRequest( let preparedTools: ReturnType | null = null if (anthropicTools?.length) { - try { - preparedTools = prepareToolsWithUsageControl( - anthropicTools, - request.tools, - logger, - providerId - ) - const { tools: filteredTools, toolChoice: tc } = preparedTools - - if (filteredTools?.length) { - anthropicTools = filteredTools - - if (typeof tc === 'object' && tc !== null) { - if (tc.type === 'tool') { - toolChoice = tc - logger.info(`Using ${providerLabel} tool_choice format: force tool "${tc.name}"`) - } else { - toolChoice = 'auto' - logger.warn(`Received non-${providerLabel} tool_choice format, defaulting to auto`) - } - } else if (tc === 'auto' || tc === 'none') { - toolChoice = tc - logger.info(`Using tool_choice mode: ${tc}`) - } else { - toolChoice = 'auto' - logger.warn('Unexpected tool_choice format, defaulting to auto') - } + preparedTools = prepareToolsWithUsageControl(anthropicTools, request.tools, logger, providerId) + const { tools: filteredTools, toolChoice: preparedToolChoice } = preparedTools + anthropicTools = filteredTools?.length + ? (filteredTools as Anthropic.Messages.Tool[]) + : undefined + + if (anthropicTools?.length) { + if (preparedToolChoice === 'auto' || preparedToolChoice === 'none') { + toolChoice = preparedToolChoice + logger.info(`Using tool_choice mode: ${preparedToolChoice}`) + } else if ( + preparedToolChoice?.type === 'tool' && + typeof preparedToolChoice.name === 'string' && + preparedToolChoice.name.length > 0 + ) { + toolChoice = preparedToolChoice + logger.info( + `Using ${providerLabel} tool_choice format: force tool "${preparedToolChoice.name}"` + ) + } else { + throw new Error(`Invalid ${providerLabel} tool choice returned by tool preparation`) } - } catch (error) { - logger.error('Error in prepareToolsWithUsageControl:', { error }) - toolChoice = 'auto' } } + /** + * System text accumulates here and is turned into blocks once, below, after + * any schema instructions are appended. Keeping it as plain strings until + * then means only one place knows the wire shape. + */ + const systemTexts = systemPrompt ? [systemPrompt] : [] + const payload: AnthropicPayload = { - model: request.model, + model: wireModel, messages, - system: systemPrompt, max_tokens: Number.parseInt(String(request.maxTokens)) || getMaxOutputTokensForModel(request.model), ...(supportsTemperature(request.model) && { @@ -320,16 +329,16 @@ export async function executeAnthropicProviderRequest( if (useNativeStructuredOutputs) { const transformedSchema = transformJSONSchema(schema) - payload.output_format = { - type: 'json_schema', - schema: transformedSchema, + payload.output_config = { + ...payload.output_config, + format: { + type: 'json_schema', + schema: transformedSchema, + }, } logger.info(`Using native structured outputs for model: ${modelId}`) } else { - const schemaInstructions = generateSchemaInstructions(schema, request.responseFormat.name) - payload.system = payload.system - ? `${payload.system}\n\n${schemaInstructions}` - : schemaInstructions + systemTexts.push(generateSchemaInstructions(schema, request.responseFormat.name)) logger.info(`Using prompt-based structured outputs for model: ${modelId}`) } } @@ -337,11 +346,18 @@ export async function executeAnthropicProviderRequest( // Add extended thinking configuration if supported and requested // The 'none' sentinel means "disable thinking" — skip configuration entirely. if (request.thinkingLevel && request.thinkingLevel !== 'none') { - const thinkingConfig = buildThinkingConfig(request.model, request.thinkingLevel) + const thinkingConfig = buildThinkingConfig( + request.model, + request.thinkingLevel, + request.agentEvents === true + ) if (thinkingConfig) { payload.thinking = thinkingConfig.thinking if (thinkingConfig.outputConfig) { - payload.output_config = thinkingConfig.outputConfig + payload.output_config = { + ...payload.output_config, + ...thinkingConfig.outputConfig, + } } // Keep budget_tokens < max_tokens (see constants above) by shrinking the budget @@ -401,7 +417,70 @@ export async function executeAnthropicProviderRequest( } } - const shouldStreamToolCalls = request.streamToolCalls ?? false + /** + * Prompt-cache breakpoints go on the static prefix, once, after tools and + * system text are final. Anthropic hashes the prefix in tools → system → + * messages order, so marking the last tool and the last system block caches + * everything ahead of the conversation, which is the part that stays byte + * stable across turns. Both tool loops spread this payload, so placing them + * here covers the streaming and non-streaming paths alike. + */ + const cacheStaticPrefix = request.promptCaching === true + if (cacheStaticPrefix && payload.tools?.length) { + payload.tools = withCacheBreakpointOnLast(payload.tools) + } + payload.system = buildSystemBlocks(systemTexts, cacheStaticPrefix) + + if (request.stream && anthropicTools && anthropicTools.length > 0) { + logger.info(`Using streaming tool loop for ${providerLabel} request`) + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createAnthropicStreamingToolLoopStream({ + anthropic, + payload, + request, + messages, + providerId, + logger, + timeSegments, + forcedTools, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } if (request.stream && (!anthropicTools || anthropicTools.length === 0)) { logger.info(`Using streaming response for ${providerLabel} request (no tools)`) @@ -425,22 +504,29 @@ export async function executeAnthropicProviderRequest( initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { total: 0.0, input: 0.0, output: 0.0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, - (content, usage) => { + ({ content, usage, thinking }) => { + const tokens = buildAnthropicUsageTokens(usage) + const cost = buildAnthropicUsageCost(request.model, usage) output.content = content - output.tokens = { - input: usage.input_tokens, - output: usage.output_tokens, - total: usage.input_tokens + usage.output_tokens, - } - - const costResult = calculateCost(request.model, usage.input_tokens, usage.output_tokens) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + output.tokens = tokens + output.cost = cost + + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.provider = providerId + segment.tokens = tokens + segment.cost = { + input: cost.input, + output: cost.output, + total: cost.total, + } + if (thinking) { + segment.thinkingContent = thinking + } } finalizeTiming() @@ -451,408 +537,6 @@ export async function executeAnthropicProviderRequest( return streamingResult } - if (request.stream && !shouldStreamToolCalls) { - logger.info( - `Using non-streaming mode for ${providerLabel} request (tool calls executed silently)` - ) - - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - try { - const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - let currentResponse = await createMessage(anthropic, payload, request.abortSignal) - const firstResponseTime = Date.now() - initialCallTime - - let content = '' - - if (Array.isArray(currentResponse.content)) { - content = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - } - - const tokens = { - input: currentResponse.usage?.input_tokens || 0, - output: currentResponse.usage?.output_tokens || 0, - total: - (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), - } - - const toolCalls = [] - const toolResults: Record[] = [] - const currentMessages = [...messages] - let iterationCount = 0 - let hasUsedForcedTool = false - let modelTime = firstResponseTime - let toolsTime = 0 - - const timeSegments: TimeSegment[] = [ - { - type: 'model', - name: request.model, - startTime: initialCallTime, - endTime: initialCallTime + firstResponseTime, - duration: firstResponseTime, - }, - ] - - const firstCheckResult = checkForForcedToolUsage( - currentResponse, - originalToolChoice, - forcedTools, - usedForcedTools - ) - if (firstCheckResult) { - hasUsedForcedTool = firstCheckResult.hasUsedForcedTool - usedForcedTools = firstCheckResult.usedForcedTools - } - - try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - const textContent = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - - if (textContent) { - content = textContent - } - - const toolUses = currentResponse.content.filter((item) => item.type === 'tool_use') - - enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { - model: request.model, - }) - - if (!toolUses || toolUses.length === 0) { - break - } - - const toolsStartTime = Date.now() - - const toolExecutionPromises = toolUses.map(async (toolUse) => { - const toolCallStartTime = Date.now() - const toolName = toolUse.name - const toolArgs = toolUse.input as Record - - try { - const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null - - const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) - const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, - }) - const toolCallEndTime = Date.now() - - return { - toolUse, - toolName, - toolArgs, - toolParams, - result, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } catch (error) { - const toolCallEndTime = Date.now() - logger.error('Error processing tool call:', { error, toolName }) - - return { - toolUse, - toolName, - toolArgs, - toolParams: {}, - result: { - success: false, - output: undefined, - error: getErrorMessage(error, 'Tool execution failed'), - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - }) - - const executionResults = await Promise.allSettled(toolExecutionPromises) - - // Collect all tool_use and tool_result blocks for batching - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] - const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - - const { - toolUse, - toolName, - toolArgs, - toolParams, - result, - startTime, - endTime, - duration, - } = settledResult.value - - timeSegments.push({ - type: 'tool', - name: toolName, - startTime: startTime, - endTime: endTime, - duration: duration, - toolCallId: toolUse.id, - }) - - let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output - } else { - resultContent = { - error: true, - message: result.error || 'Tool execution failed', - tool: toolName, - } - } - - toolCalls.push({ - name: toolName, - arguments: toolParams, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - duration: duration, - result: resultContent, - success: result.success, - }) - - // Add to batched arrays using the ORIGINAL ID from Claude's response - toolUseBlocks.push({ - type: 'tool_use', - id: toolUse.id, - name: toolName, - input: toolArgs, - }) - - toolResultBlocks.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - content: JSON.stringify(resultContent), - }) - } - - // Per Anthropic docs: thinking blocks must be preserved in assistant messages - // during tool use to maintain reasoning continuity. - const thinkingBlocks = currentResponse.content.filter( - ( - item - ): item is - | Anthropic.Messages.ThinkingBlock - | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' - ) - - // Add ONE assistant message with thinking + tool_use blocks - if (toolUseBlocks.length > 0) { - currentMessages.push({ - role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], - }) - } - - // Add ONE user message with ALL tool_result blocks - if (toolResultBlocks.length > 0) { - currentMessages.push({ - role: 'user', - content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], - }) - } - - const thisToolsTime = Date.now() - toolsStartTime - toolsTime += thisToolsTime - - const nextPayload: AnthropicPayload = { - ...payload, - messages: currentMessages, - } - - // Per Anthropic docs: forced tool_choice is incompatible with thinking. - // Only auto and none are supported when thinking is enabled. - const thinkingEnabled = !!payload.thinking - if ( - !thinkingEnabled && - typeof originalToolChoice === 'object' && - hasUsedForcedTool && - forcedTools.length > 0 - ) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - nextPayload.tool_choice = { - type: 'tool', - name: remainingTools[0], - } - logger.info(`Forcing next tool: ${remainingTools[0]}`) - } else { - nextPayload.tool_choice = undefined - logger.info('All forced tools have been used, removing tool_choice parameter') - } - } else if ( - !thinkingEnabled && - hasUsedForcedTool && - typeof originalToolChoice === 'object' - ) { - nextPayload.tool_choice = undefined - logger.info( - 'Removing tool_choice parameter for subsequent requests after forced tool was used' - ) - } - - const nextModelStartTime = Date.now() - - currentResponse = await createMessage(anthropic, nextPayload, request.abortSignal) - - const nextCheckResult = checkForForcedToolUsage( - currentResponse, - nextPayload.tool_choice, - forcedTools, - usedForcedTools - ) - if (nextCheckResult) { - hasUsedForcedTool = nextCheckResult.hasUsedForcedTool - usedForcedTools = nextCheckResult.usedForcedTools - } - - const nextModelEndTime = Date.now() - const thisModelTime = nextModelEndTime - nextModelStartTime - - timeSegments.push({ - type: 'model', - name: request.model, - startTime: nextModelStartTime, - endTime: nextModelEndTime, - duration: thisModelTime, - }) - - modelTime += thisModelTime - - if (currentResponse.usage) { - tokens.input += currentResponse.usage.input_tokens || 0 - tokens.output += currentResponse.usage.output_tokens || 0 - tokens.total += - (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) - } - - iterationCount++ - } - - if (iterationCount === MAX_TOOL_ITERATIONS) { - const trailingText = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { - model: request.model, - }) - } - } catch (error) { - logger.error(`Error in ${providerLabel} request:`, { error }) - throw error - } - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const streamingPayload = { - ...payload, - messages: currentMessages, - stream: true, - tool_choice: undefined, - } - - const streamResponse = await anthropic.messages.create( - streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - isStreaming: true, - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - (streamContent, usage) => { - output.content = streamContent - output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.input_tokens, - usage.output_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - - finalizeTiming() - } - ), - }) - - return streamingResult - } catch (error) { - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - logger.error(`Error in ${providerLabel} request:`, { - error, - duration: totalDuration, - }) - - throw new ProviderError(toError(error).message, { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - }) - } - } - const providerStartTime = Date.now() const providerStartTimeISO = new Date(providerStartTime).toISOString() @@ -874,23 +558,8 @@ export async function executeAnthropicProviderRequest( .join('\n') } - const tokens = { - input: currentResponse.usage?.input_tokens || 0, - output: currentResponse.usage?.output_tokens || 0, - total: - (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), - } - - const initialCost = calculateCost( - request.model, - currentResponse.usage?.input_tokens || 0, - currentResponse.usage?.output_tokens || 0 - ) - const cost = { - input: initialCost.input, - output: initialCost.output, - total: initialCost.total, - } + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, currentResponse.usage) const toolCalls = [] const toolResults: Record[] = [] @@ -936,9 +605,17 @@ export async function executeAnthropicProviderRequest( enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { model: request.model, + providerId, }) - if (!toolUses || toolUses.length === 0) { + if (toolUses.length > 0 && currentResponse.stop_reason !== 'tool_use') { + throw new Error( + `${providerLabel} returned tool use with stop_reason ${ + currentResponse.stop_reason ?? 'missing' + }` + ) + } + if (toolUses.length === 0) { break } @@ -947,17 +624,36 @@ export async function executeAnthropicProviderRequest( const toolExecutionPromises = toolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name - const toolArgs = toolUse.input as Record + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined // Preserve the original tool_use ID from Claude's response const toolUseId = toolUse.id try { + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolUseId, + toolName, + toolArgs, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { - skipPostProcess: true, signal: request.abortSignal, }) const toolCallEndTime = Date.now() @@ -973,13 +669,16 @@ export async function executeAnthropicProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) return { toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams: {}, result: { success: false, @@ -993,15 +692,12 @@ export async function executeAnthropicProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) // Collect all tool_use and tool_result blocks for batching - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolUseId, toolName, @@ -1011,7 +707,7 @@ export async function executeAnthropicProviderRequest( startTime, endTime, duration, - } = settledResult.value + } = executionResult timeSegments.push({ type: 'tool', @@ -1023,9 +719,11 @@ export async function executeAnthropicProviderRequest( }) let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -1044,38 +742,32 @@ export async function executeAnthropicProviderRequest( success: result.success, }) - // Add to batched arrays using the ORIGINAL ID from Claude's response - toolUseBlocks.push({ - type: 'tool_use', - id: toolUseId, - name: toolName, - input: toolArgs, - }) - toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUseId, content: JSON.stringify(resultContent), + is_error: !result.success, }) } - // Per Anthropic docs: thinking blocks must be preserved in assistant messages - // during tool use to maintain reasoning continuity. - const thinkingBlocks = currentResponse.content.filter( + const assistantBlocks = currentResponse.content.filter( ( item - ): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' + ): item is + | Anthropic.Messages.TextBlock + | Anthropic.Messages.ThinkingBlock + | Anthropic.Messages.RedactedThinkingBlock + | Anthropic.Messages.ToolUseBlock => + item.type === 'text' || + item.type === 'thinking' || + item.type === 'redacted_thinking' || + item.type === 'tool_use' ) - // Add ONE assistant message with thinking + tool_use blocks - if (toolUseBlocks.length > 0) { + if (assistantBlocks.some((item) => item.type === 'tool_use')) { currentMessages.push({ role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], + content: assistantBlocks, }) } @@ -1155,21 +847,7 @@ export async function executeAnthropicProviderRequest( modelTime += thisModelTime - if (currentResponse.usage) { - tokens.input += currentResponse.usage.input_tokens || 0 - tokens.output += currentResponse.usage.output_tokens || 0 - tokens.total += - (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) - - const iterationCost = calculateCost( - request.model, - currentResponse.usage.input_tokens || 0, - currentResponse.usage.output_tokens || 0 - ) - cost.input += iterationCost.input - cost.output += iterationCost.output - cost.total += iterationCost.total - } + addAnthropicUsage(usage, currentResponse.usage) iterationCount++ } @@ -1181,6 +859,7 @@ export async function executeAnthropicProviderRequest( .join('\n') enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { model: request.model, + providerId, }) } } catch (error) { @@ -1191,79 +870,14 @@ export async function executeAnthropicProviderRequest( const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - - if (request.stream) { - logger.info(`Using streaming for final ${providerLabel} response after tool processing`) - - const streamingPayload = { - ...payload, - messages: currentMessages, - stream: true, - tool_choice: undefined, - } - - const streamResponse = await anthropic.messages.create( - streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: cost.input, - output: cost.output, - toolCost: undefined as number | undefined, - total: cost.total, - }, - toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - isStreaming: true, - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - (streamContent, usage) => { - output.content = streamContent - output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.input_tokens, - usage.output_tokens - ) - const tc2 = sumToolCosts(toolResults) - output.cost = { - input: cost.input + streamCost.input, - output: cost.output + streamCost.output, - toolCost: tc2 || undefined, - total: cost.total + streamCost.total + tc2, - } - - finalizeTiming() - } - ), - }) - - return streamingResult - } + const tokens = buildAnthropicUsageTokens(usage) + const cost = buildAnthropicUsageCost(request.model, usage) return { content, model: request.model, tokens, + cost, toolCalls: toolCalls.length > 0 ? toolCalls.map((tc) => ({ @@ -1297,6 +911,10 @@ export async function executeAnthropicProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, @@ -1316,6 +934,7 @@ function enrichLastModelSegmentFromAnthropicResponse( textContent: string, extras?: { model?: string + providerId?: string ttft?: number errorType?: string errorMessage?: string @@ -1341,17 +960,12 @@ function enrichLastModelSegmentFromAnthropicResponse( : {}, })) - const segmentTokens = response.usage ? buildAnthropicSegmentTokens(response.usage) : undefined - + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, response.usage) + const segmentTokens = buildAnthropicUsageTokens(usage) let cost: { input: number; output: number; total: number } | undefined - if ( - extras?.model && - segmentTokens && - typeof segmentTokens.input === 'number' && - typeof segmentTokens.output === 'number' - ) { - const useCached = (segmentTokens.cacheRead ?? 0) > 0 - const full = calculateCost(extras.model, segmentTokens.input, segmentTokens.output, useCached) + if (extras?.model) { + const full = buildAnthropicUsageCost(extras.model, usage) cost = { input: full.input, output: full.output, total: full.total } } @@ -1362,29 +976,9 @@ function enrichLastModelSegmentFromAnthropicResponse( finishReason: response.stop_reason ?? undefined, tokens: segmentTokens, cost, - provider: 'anthropic', + provider: extras?.providerId, ttft: extras?.ttft, errorType: extras?.errorType, errorMessage: extras?.errorMessage, }) } - -/** - * Builds a segment token breakdown from Anthropic usage data, surfacing prompt - * cache reads/writes separately and producing a corrected `total` that includes - * cache_creation tokens (which Anthropic bills as input tokens but omits from - * `input_tokens`). - */ -function buildAnthropicSegmentTokens(usage: Anthropic.Messages.Message['usage']): BlockTokens { - const input = usage.input_tokens ?? 0 - const output = usage.output_tokens ?? 0 - const cacheRead = usage.cache_read_input_tokens ?? 0 - const cacheWrite = usage.cache_creation_input_tokens ?? 0 - return { - input, - output, - total: input + output + cacheRead + cacheWrite, - ...(cacheRead > 0 && { cacheRead }), - ...(cacheWrite > 0 && { cacheWrite }), - } -} diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 043ae4b0f09..80129eb3cfc 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -22,18 +22,9 @@ export const anthropicProvider: ProviderConfig = { return executeAnthropicProviderRequest(request, { providerId: 'anthropic', providerLabel: 'Anthropic', - createClient: (apiKey, useNativeStructuredOutputs) => { - const cacheKey = `anthropic::${apiKey}::${useNativeStructuredOutputs ? 'beta' : 'default'}` - return getCachedProviderClient( - cacheKey, - () => - new Anthropic({ - apiKey, - defaultHeaders: useNativeStructuredOutputs - ? { 'anthropic-beta': 'structured-outputs-2025-11-13' } - : undefined, - }) - ) + createClient: (apiKey) => { + const cacheKey = `anthropic::${apiKey}` + return getCachedProviderClient(cacheKey, () => new Anthropic({ apiKey })) }, logger, }) diff --git a/apps/sim/providers/anthropic/request-history.test.ts b/apps/sim/providers/anthropic/request-history.test.ts new file mode 100644 index 00000000000..9dfac540180 --- /dev/null +++ b/apps/sim/providers/anthropic/request-history.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { convertAnthropicRequestHistory } from '@/providers/anthropic/request-history' + +describe('convertAnthropicRequestHistory', () => { + it('merges system history into the top-level prompt and preserves ordinary messages', () => { + const result = convertAnthropicRequestHistory({ + systemPrompt: 'Base instructions', + providerId: 'anthropic', + messages: [ + { role: 'system', content: 'First historical instruction' }, + { role: 'user', content: 'Hello' }, + { role: 'system', content: 'Second historical instruction' }, + { role: 'assistant', content: 'Hi there' }, + ], + }) + + expect(result.systemPrompt).toBe( + 'Base instructions\n\nFirst historical instruction\n\nSecond historical instruction' + ) + expect(result.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] }, + ]) + }) + + it('preserves modern tool IDs, parsed arguments, assistant text, and matching tool results', () => { + const result = convertAnthropicRequestHistory({ + providerId: 'anthropic', + messages: [ + { + role: 'assistant', + content: 'I will check both.', + tool_calls: [ + { + id: 'call-weather', + type: 'function', + function: { name: 'weather', arguments: '{"city":"Paris"}' }, + }, + { + id: 'call-time', + type: 'function', + function: { name: 'time', arguments: '{"timezone":"UTC"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call-weather', content: '' }, + { role: 'tool', tool_call_id: 'call-time', content: '00:00' }, + ], + }) + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'I will check both.' }, + { + type: 'tool_use', + id: 'call-weather', + name: 'weather', + input: { city: 'Paris' }, + }, + { + type: 'tool_use', + id: 'call-time', + name: 'time', + input: { timezone: 'UTC' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call-weather', content: '' }, + { type: 'tool_result', tool_use_id: 'call-time', content: '00:00' }, + ], + }, + ]) + }) + + it('pairs legacy function calls and results with stable deterministic IDs', () => { + const options = { + providerId: 'anthropic', + messages: [ + { + role: 'assistant' as const, + content: 'Checking.', + function_call: { name: 'lookup', arguments: '{"id":0}' }, + }, + { role: 'function' as const, name: 'lookup', content: 'false' }, + ], + } + + const first = convertAnthropicRequestHistory(options) + const second = convertAnthropicRequestHistory(options) + const firstToolUse = first.messages[0].content[1] + const secondToolUse = second.messages[0].content[1] + + expect(firstToolUse).toMatchObject({ + type: 'tool_use', + name: 'lookup', + input: { id: 0 }, + }) + expect(secondToolUse).toEqual(firstToolUse) + expect(first.messages[1]).toEqual({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: (firstToolUse as { id: string }).id, + content: 'false', + }, + ], + }) + }) + + it.each([ + ['malformed JSON', '{"id":'], + ['non-object JSON', '[]'], + ])('rejects %s legacy function arguments', (_label, args) => { + expect(() => + convertAnthropicRequestHistory({ + providerId: 'anthropic', + messages: [ + { + role: 'assistant', + content: null, + function_call: { name: 'lookup', arguments: args }, + }, + { role: 'function', name: 'lookup', content: 'result' }, + ], + }) + ).toThrow(/tool "lookup"/) + }) +}) diff --git a/apps/sim/providers/anthropic/request-history.ts b/apps/sim/providers/anthropic/request-history.ts new file mode 100644 index 00000000000..6909bda8747 --- /dev/null +++ b/apps/sim/providers/anthropic/request-history.ts @@ -0,0 +1,162 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { buildAnthropicMessageContent } from '@/providers/attachments' +import { parseToolArguments } from '@/providers/streaming-tool-loop-shared' +import type { Message } from '@/providers/types' + +interface ConvertAnthropicRequestHistoryOptions { + messages?: Message[] + systemPrompt?: string + context?: string + providerId: string +} + +interface ConvertedAnthropicRequestHistory { + messages: Anthropic.Messages.MessageParam[] + systemPrompt: string +} + +interface PendingToolCall { + id: string + name: string +} + +/** + * Converts Sim's shared request history into Anthropic's message protocol. + */ +export function convertAnthropicRequestHistory({ + messages: sourceMessages = [], + systemPrompt, + context, + providerId, +}: ConvertAnthropicRequestHistoryOptions): ConvertedAnthropicRequestHistory { + const convertedMessages: Anthropic.Messages.MessageParam[] = [] + const systemParts = systemPrompt ? [systemPrompt] : [] + const pendingToolCalls = new Map() + let toolResultBlocks: Anthropic.Messages.ContentBlockParam[] | undefined + + if (context) { + convertedMessages.push({ + role: 'user', + content: [{ type: 'text', text: context }], + }) + } + + const assertNoPendingToolCalls = () => { + if (pendingToolCalls.size > 0) { + throw new Error( + `Anthropic request history is missing tool results for: ${[...pendingToolCalls.values()] + .map(({ name, id }) => `${name} (${id})`) + .join(', ')}` + ) + } + toolResultBlocks = undefined + } + + const registerToolCall = (toolCall: PendingToolCall) => { + if (!toolCall.id) { + throw new Error(`Anthropic tool call "${toolCall.name}" is missing an ID`) + } + if (pendingToolCalls.has(toolCall.id)) { + throw new Error(`Anthropic request history contains duplicate tool call ID "${toolCall.id}"`) + } + pendingToolCalls.set(toolCall.id, toolCall) + } + + const appendToolResult = (toolCallId: string | undefined, content: string | null) => { + if (!toolCallId || !pendingToolCalls.has(toolCallId)) { + throw new Error( + `Anthropic request history contains a tool result without a matching tool call${ + toolCallId ? `: ${toolCallId}` : '' + }` + ) + } + + const resultBlock: Anthropic.Messages.ToolResultBlockParam = { + type: 'tool_result', + tool_use_id: toolCallId, + ...(content !== null ? { content } : {}), + } + + if (!toolResultBlocks) { + toolResultBlocks = [resultBlock] + convertedMessages.push({ role: 'user', content: toolResultBlocks }) + } else { + toolResultBlocks.push(resultBlock) + } + + pendingToolCalls.delete(toolCallId) + } + + sourceMessages.forEach((message, messageIndex) => { + if (message.role === 'system') { + if (message.content) { + systemParts.push(message.content) + } + return + } + + if (message.role === 'tool') { + appendToolResult(message.tool_call_id, message.content) + return + } + + if (message.role === 'function') { + const matchingCall = [...pendingToolCalls.values()].find( + (toolCall) => toolCall.name === message.name + ) + appendToolResult(matchingCall?.id, message.content) + return + } + + assertNoPendingToolCalls() + + const content = buildAnthropicMessageContent(message.content, message.files, providerId) + if (message.role === 'assistant' && message.tool_calls?.length) { + const toolUseBlocks = message.tool_calls.map((toolCall) => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parseToolArguments(toolCall.function.arguments, toolCall.function.name), + } + registerToolCall({ id: block.id, name: block.name }) + return block + }) + convertedMessages.push({ + role: 'assistant', + content: [...content, ...toolUseBlocks], + }) + return + } + + if (message.role === 'assistant' && message.function_call) { + const toolUseId = `legacy-function-call-${messageIndex}` + const toolUseBlock: Anthropic.Messages.ToolUseBlockParam = { + type: 'tool_use', + id: toolUseId, + name: message.function_call.name, + input: parseToolArguments(message.function_call.arguments, message.function_call.name), + } + registerToolCall({ id: toolUseId, name: toolUseBlock.name }) + convertedMessages.push({ + role: 'assistant', + content: [...content, toolUseBlock], + }) + return + } + + if (content.length > 0) { + convertedMessages.push({ + role: message.role === 'assistant' ? 'assistant' : 'user', + content, + }) + } + }) + + assertNoPendingToolCalls() + + return { + messages: convertedMessages, + systemPrompt: systemParts.join('\n\n'), + } +} diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..c0f730af6c1 --- /dev/null +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -0,0 +1,588 @@ +/** + * @vitest-environment node + * + * Anthropic streaming tool loop — live tool_call_start/end, live `pending` + * text classified by turn_end, abort → cancelled, per-turn usage accumulation. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' +import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import type { AnthropicUsageLike } from '@/providers/anthropic/usage' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { TimeSegment } from '@/providers/types' + +const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), + mockPrepareToolExecution: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: mockPrepareToolExecution, + calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), + sumToolCosts: () => 0, + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), +})) + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +function makeFinalMessage(overrides: { + content: unknown[] + usage?: AnthropicUsageLike + stop_reason?: string | null +}) { + return { + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: overrides.content, + stop_reason: overrides.stop_reason ?? null, + stop_sequence: null, + usage: overrides.usage ?? { input_tokens: 10, output_tokens: 20 }, + } +} + +function makeMessageStream(events: unknown[], finalMessage: ReturnType) { + return { + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event + } + }, + finalMessage: async () => finalMessage, + } +} + +describe('createAnthropicStreamingToolLoopStream', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + beforeEach(() => { + vi.clearAllMocks() + mockPrepareToolExecution.mockReturnValue({ + toolParams: { city: 'San Francisco' }, + executionParams: { city: 'San Francisco' }, + }) + mockExecuteTool.mockResolvedValue({ + success: true, + output: { temp: 68 }, + }) + }) + + it('emits tool_call_start/end, live pending text with turn_end classification, and accumulates usage', async () => { + const toolTurnEvents = anthropicThinkingTextToolStreamEvents + const toolTurnMessage = makeFinalMessage({ + content: [ + { + type: 'thinking', + thinking: anthropicThinkingTextToolExpectedThinking, + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + { type: 'text', text: 'Let me check the weather in San Francisco.' }, + { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: { city: 'San Francisco' }, + }, + ], + usage: { + input_tokens: 42, + cache_read_input_tokens: 8, + cache_creation_input_tokens: 10, + output_tokens: 30, + }, + stop_reason: 'tool_use', + }) + + const finalTurnEvents = [ + { + type: 'message_start', + message: { + usage: { input_tokens: 100, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'It is 68°F in San Francisco.' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 12 }, + }, + { type: 'message_stop' }, + ] + const finalTurnMessage = makeFinalMessage({ + content: [{ type: 'text', text: 'It is 68°F in San Francisco.' }], + usage: { + input_tokens: 100, + cache_creation_input_tokens: 5, + cache_creation: { + ephemeral_5m_input_tokens: 2, + ephemeral_1h_input_tokens: 3, + }, + output_tokens: 12, + }, + stop_reason: 'end_turn', + }) + + let streamCall = 0 + const anthropic = { + messages: { + stream: vi.fn(() => { + streamCall++ + if (streamCall === 1) { + return makeMessageStream(toolTurnEvents as unknown[], toolTurnMessage) + } + return makeMessageStream(finalTurnEvents, finalTurnMessage) + }), + }, + } as any + + const timeSegments: TimeSegment[] = [] + const onComplete = vi.fn() + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'Weather?' }], + tools: [ + { + name: 'get_weather', + description: 'Get weather', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'Weather?' }], + providerId: 'anthropic', + logger, + timeSegments, + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').length).toBeGreaterThan(0) + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + }) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + status: 'success', + }) + + // All text streams live as `pending`; the pump classifies via turn_end. + const textEvents = events.filter((e) => e.type === 'text_delta') + expect(textEvents.every((e) => e.turn === 'pending')).toBe(true) + expect(textEvents.some((e) => e.text.includes('Let me check'))).toBe(true) + expect(textEvents.some((e) => e.text.includes('68°F'))).toBe(true) + + const turnEnds = events.filter((e) => e.type === 'turn_end') + expect(turnEnds.map((e) => e.turn)).toEqual(['intermediate', 'final']) + + // Ordering: the tool turn's pending text precedes its intermediate turn_end, + // and the final turn's text precedes the final turn_end. + const eventKinds = events.map((e) => + e.type === 'turn_end' ? `turn_end:${e.turn}` : e.type === 'text_delta' ? 'text' : e.type + ) + expect(eventKinds.indexOf('text')).toBeLessThan(eventKinds.indexOf('turn_end:intermediate')) + expect(eventKinds.lastIndexOf('text')).toBeLessThan(eventKinds.indexOf('turn_end:final')) + + // Assistant history must keep thinking signature for multi-iteration round-trip. + const secondPayload = anthropic.messages.stream.mock.calls[1][0] + const assistantMsg = secondPayload.messages.find((m: any) => m.role === 'assistant') + expect(assistantMsg.content.some((b: any) => b.type === 'thinking' && b.signature)).toBe(true) + expect(assistantMsg.content.some((b: any) => b.type === 'tool_use')).toBe(true) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete.mock.calls[0][0].tokens).toEqual({ + input: 142, + output: 42, + total: 207, + cacheRead: 8, + cacheWrite: 15, + }) + expect(onComplete.mock.calls[0][0].content).toContain('68°F') + expect(mockExecuteTool).toHaveBeenCalled() + }) + + it('settles in-flight tools as cancelled on abort', async () => { + const abortController = new AbortController() + const toolStartEvents = [ + { + type: 'message_start', + message: { usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{}' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 3 }, + }, + { type: 'message_stop' }, + ] + + mockExecuteTool.mockImplementation(async () => { + abortController.abort() + throw new DOMException('Stream aborted', 'AbortError') + }) + + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + toolStartEvents, + makeFinalMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + ], + stop_reason: 'tool_use', + }) + ) + ), + }, + } as any + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + abortSignal: abortController.signal, + } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch { + // expected — stream errors after abort settlement + } + + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_abort', + name: 'get_weather', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_abort', + name: 'get_weather', + status: 'cancelled', + }) + }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'message_start', + message: { usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{}' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 3 }, + }, + { type: 'message_stop' }, + ], + makeFinalMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 5, output_tokens: 3 }, + }) + ) + ), + }, + } as any + const onComplete = vi.fn() + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ + tokens: expect.objectContaining({ input: 5, output: 3, total: 8 }), + }) + ) + }) + + it('finalizes truncated text when max_tokens is reached without a tool call', async () => { + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Partial answer' }, + }, + ], + makeFinalMessage({ + content: [{ type: 'text', text: 'Partial answer' }], + stop_reason: 'max_tokens', + }) + ) + ), + }, + } as any + const onComplete = vi.fn() + const timeSegments: TimeSegment[] = [] + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'x' }], + }, + request: { model: 'claude-sonnet-4-5' } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments, + onComplete, + }) + + await expect(collectEvents(stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Partial answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Partial answer', + tokens: expect.objectContaining({ input: 10, output: 20, total: 30 }), + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'max_tokens', + assistantContent: 'Partial answer', + }) + }) + + it('rejects a max_tokens turn containing a partial tool call', async () => { + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_partial', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"city":' }, + }, + ], + makeFinalMessage({ + content: [], + stop_reason: 'max_tokens', + }) + ) + ), + }, + } as any + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toMatchObject({ + message: 'Anthropic stream ended with stop_reason max_tokens', + }) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_partial', + name: 'get_weather', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_partial', + name: 'get_weather', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts new file mode 100644 index 00000000000..1a87ae4a3d8 --- /dev/null +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -0,0 +1,579 @@ +/** + * Live Anthropic streaming tool loop. + * + * Each model turn is streamed via `messages.stream` + `finalMessage()` so thinking + * signatures round-trip correctly. Thinking, `tool_call_start`, and `pending` + * text deltas emit live; a `turn_end` event classifies the turn as + * `intermediate` (tool-use turns) or `final` so the pump projects only final + * text to the answer channel. Tool ends emit in actual completion order; abort + * settles in-flight tools as `cancelled`. + */ + +import type Anthropic from '@anthropic-ai/sdk' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' +import { checkForForcedToolUsage } from '@/providers/anthropic/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + type StreamingToolLoopComplete, + settleOpenTools, + terminateToolLoop, +} from '@/providers/streaming-tool-loop-shared' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' + +export type AnthropicStreamingToolLoopPayload = Anthropic.Messages.MessageStreamParams + +type AnthropicStreamingToolLoopComplete = Omit & { + tokens: ReturnType +} + +export interface CreateAnthropicStreamingToolLoopStreamOptions { + anthropic: Anthropic + payload: AnthropicStreamingToolLoopPayload + request: ProviderRequest + messages: Anthropic.Messages.MessageParam[] + providerId: string + logger: Logger + /** Shared mutable segments; same array reference passed into createStreamingExecution. */ + timeSegments: TimeSegment[] + /** Forced tool names from prepareToolsWithUsageControl (may be empty). */ + forcedTools?: string[] + onComplete: (result: AnthropicStreamingToolLoopComplete) => void +} + +function enrichModelSegment( + timeSegments: TimeSegment[], + response: Anthropic.Messages.Message, + textContent: string, + model: string, + providerId: string +): void { + const thinkingBlocks = response.content.filter( + (item): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => + item.type === 'thinking' || item.type === 'redacted_thinking' + ) + const thinkingContent = thinkingBlocks + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + + const toolUseBlocks = response.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + const toolCalls: IterationToolCall[] = toolUseBlocks.map((t) => ({ + id: t.id, + name: t.name, + arguments: + t.input && typeof t.input === 'object' && !Array.isArray(t.input) + ? (t.input as Record) + : {}, + })) + + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, response.usage) + const segmentTokens = buildAnthropicUsageTokens(usage) + const segmentCost = buildAnthropicUsageCost(model, usage) + + enrichLastModelSegment(timeSegments, { + assistantContent: textContent || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: response.stop_reason ?? undefined, + tokens: segmentTokens, + cost: { + input: segmentCost.input, + output: segmentCost.output, + total: segmentCost.total, + }, + provider: providerId, + }) +} + +/** + * Multi-turn Anthropic tool loop as an `agent-events-v1` object stream. + */ +export function createAnthropicStreamingToolLoopStream( + options: CreateAnthropicStreamingToolLoopStreamOptions +): ReadableStream { + const { anthropic, payload, request, messages, providerId, logger, timeSegments, onComplete } = + options + const forcedToolNames = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeMessageStream: { abort: () => void } | undefined + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...messages] + const originalToolChoice = payload.tool_choice + + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const usage = createAnthropicUsageAccumulator() + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + /** Tools that received start but not yet end (abort settlement). */ + const openToolStarts = new Map() + + const streamOptions = { signal: loopAbortController.signal } + const reportProgress = () => { + const tokens = buildAnthropicUsageTokens(usage) + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: buildAnthropicUsageCost(request.model, usage, toolCost), + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } + + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + const abortErr = new DOMException('Stream aborted', 'AbortError') + settleOpenTools(controller, openToolStarts, 'cancelled') + throw abortErr + } + + const turnPayload: AnthropicStreamingToolLoopPayload = { + ...payload, + messages: currentMessages, + } + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + // Streaming tool loop always streams each turn; never pass stream:true twice. + ;(turnPayload as { stream?: boolean }).stream = undefined + if (finalSynthesis) { + turnPayload.tool_choice = { type: 'none' } + } + + // Forced tool_choice vs thinking — same rules as silent loop. + const thinkingEnabled = !!payload.thinking + if ( + !finalSynthesis && + !thinkingEnabled && + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedToolNames.length > 0 + ) { + const remainingTools = forcedToolNames.filter((tool) => !usedForcedTools.includes(tool)) + if (remainingTools.length > 0) { + turnPayload.tool_choice = { type: 'tool', name: remainingTools[0] } + } else { + turnPayload.tool_choice = undefined + } + } else if ( + !finalSynthesis && + !thinkingEnabled && + hasUsedForcedTool && + typeof originalToolChoice === 'object' + ) { + turnPayload.tool_choice = undefined + } + + const modelStart = Date.now() + const messageStream = anthropic.messages.stream(turnPayload, streamOptions) + activeMessageStream = messageStream + + const textChunks: string[] = [] + + try { + for await (const event of messageStream) { + if (event.type === 'content_block_start') { + const block = event.content_block + if (block.type === 'tool_use' && block.id && block.name) { + openToolStarts.set(block.id, block.name) + controller.enqueue({ + type: 'tool_call_start', + id: block.id, + name: block.name, + }) + } + continue + } + if (event.type !== 'content_block_delta') { + continue + } + const delta = event.delta + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + controller.enqueue({ type: 'thinking_delta', text: delta.thinking }) + continue + } + if (delta.type === 'text_delta' && typeof delta.text === 'string') { + textChunks.push(delta.text) + // Live pending text: sinks render it now; the pump projects it + // to the answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'pending' }) + } + } + + const finalMessage = await messageStream.finalMessage() + activeMessageStream = undefined + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + addAnthropicUsage(usage, finalMessage.usage) + + const textContent = finalMessage.content + .filter((item): item is Anthropic.Messages.TextBlock => item.type === 'text') + .map((item) => item.text) + .join('\n') + + const toolUses = finalMessage.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + /** + * Only execute tools when the model actually stopped to call them. + * On `max_tokens` / `malformed_tool_use` the assembled inputs are + * truncated best-effort JSON — running tools on them would execute + * with wrong or partial arguments. + */ + const toolsExecutable = finalMessage.stop_reason === 'tool_use' + if (toolUses.length > 0 && !toolsExecutable) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Anthropic returned tool use with stop_reason ${finalMessage.stop_reason ?? 'missing'}` + ) + } + if (finalSynthesis && toolUses.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Anthropic returned tool use during final synthesis') + } + const executableToolUses = toolsExecutable ? toolUses : [] + const cappedTextTurn = + finalMessage.stop_reason === 'max_tokens' && openToolStarts.size === 0 + if ( + executableToolUses.length === 0 && + finalMessage.stop_reason !== 'end_turn' && + finalMessage.stop_reason !== 'stop_sequence' && + !cappedTextTurn + ) { + throw new Error( + `Anthropic stream ended with stop_reason ${finalMessage.stop_reason ?? 'missing'}` + ) + } + + const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' + // If the SDK assembled text but we somehow missed deltas, still emit it + // before the boundary so the turn_end classification covers it. + if (textChunks.length === 0 && textContent) { + controller.enqueue({ type: 'text_delta', text: textContent, turn: 'pending' }) + } + controller.enqueue({ type: 'turn_end', turn: turnTag }) + content = textChunks.length > 0 ? textChunks.join('') : textContent + + enrichModelSegment(timeSegments, finalMessage, textContent, request.model, providerId) + + const forcedCheck = checkForForcedToolUsage( + finalMessage, + turnPayload.tool_choice, + forcedToolNames, + usedForcedTools + ) + if (forcedCheck) { + hasUsedForcedTool = forcedCheck.hasUsedForcedTool + usedForcedTools = forcedCheck.usedForcedTools + } + + if (executableToolUses.length === 0) { + sawFinalTurn = true + break + } + + const toolsStartTime = Date.now() + + // Emit ends in completion order; keep Promise.all result order (= start order) for history. + const orderedResults = await Promise.all( + executableToolUses.map(async (toolUse) => { + const toolCallStartTime = Date.now() + const toolName = toolUse.name + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined + + try { + if (loopAbortController.signal.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUse, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: loopAbortController.signal, + }) + const toolCallEndTime = Date.now() + const value = { + toolUse, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (result.success ? 'success' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: value.status, + }) + return value + } catch (error) { + const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'cancelled', + }) + throw error + } + if (isAbortError(error)) { + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) + const value = { + toolUse, + toolName, + toolArgs: toolArgs ?? {}, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: value.status, + }) + return value + } + }) + ) + + const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] + + for (const value of orderedResults) { + const { + toolUse, + toolName, + toolArgs, + toolParams, + result, + startTime, + endTime, + duration, + } = value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime, + endTime, + duration, + toolCallId: toolUse.id, + }) + + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration, + result: resultContent, + success: result.success, + }) + + toolResultBlocks.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: JSON.stringify(resultContent), + is_error: !result.success, + }) + } + + const assistantBlocks = finalMessage.content.filter( + ( + item + ): item is + | Anthropic.Messages.TextBlock + | Anthropic.Messages.ThinkingBlock + | Anthropic.Messages.RedactedThinkingBlock + | Anthropic.Messages.ToolUseBlock => + item.type === 'text' || + item.type === 'thinking' || + item.type === 'redacted_thinking' || + item.type === 'tool_use' + ) + + if (assistantBlocks.some((item) => item.type === 'tool_use')) { + currentMessages.push({ + role: 'assistant', + content: assistantBlocks, + }) + } + if (toolResultBlocks.length > 0) { + currentMessages.push({ + role: 'user', + content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], + }) + } + + toolsTime += Date.now() - toolsStartTime + iterationCount++ + + if (loopAbortController.signal.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + } catch (error) { + settleOpenTools( + controller, + openToolStarts, + loopAbortController.signal.aborted ? 'cancelled' : 'error' + ) + throw error + } + } + + if (!sawFinalTurn) { + throw new Error('Anthropic tool loop ended without a final response') + } + + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Anthropic streaming tool loop failed', { + error: toError(cause).message, + }), + }) + } finally { + activeMessageStream = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) + } + }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + activeMessageStream?.abort() + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, + }) +} diff --git a/apps/sim/providers/anthropic/usage.test.ts b/apps/sim/providers/anthropic/usage.test.ts new file mode 100644 index 00000000000..5ee1fd12c77 --- /dev/null +++ b/apps/sim/providers/anthropic/usage.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' + +const MODEL = 'claude-sonnet-4-5' + +describe('Anthropic usage aggregation', () => { + it('prices uncached input and output normally', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { input_tokens: 1_000_000, output_tokens: 1_000_000 }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 1_000_000, + output: 1_000_000, + total: 2_000_000, + cacheRead: 0, + cacheWrite: 0, + }) + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3, + output: 15, + total: 18, + }) + }) + + it('prices cache reads at cached-input rates without discounting uncached input', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3.3, + output: 0, + total: 3.3, + }) + }) + + it('prices cache writes without details at the default five-minute tier', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3.75, + total: 3.75, + }) + }) + + it('prices one-hour cache writes at twice the normal input rate', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 1_000_000, + }, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 6, + total: 6, + }) + }) + + it('aggregates mixed uncached, read, five-minute, one-hour, and output usage', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 100_000, + cache_read_input_tokens: 200_000, + cache_creation_input_tokens: 300_000, + cache_creation: { + ephemeral_5m_input_tokens: 100_000, + ephemeral_1h_input_tokens: 200_000, + }, + output_tokens: 400_000, + }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 100_000, + output: 400_000, + total: 1_000_000, + cacheRead: 200_000, + cacheWrite: 300_000, + }) + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 1.935, + output: 6, + total: 7.935, + }) + }) + + it('accumulates each stream turn exactly once', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + output_tokens: 40, + }) + addAnthropicUsage(usage, { + input_tokens: 1, + cache_read_input_tokens: 2, + cache_creation_input_tokens: 3, + output_tokens: 4, + }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 11, + output: 44, + total: 110, + cacheRead: 22, + cacheWrite: 33, + }) + }) +}) diff --git a/apps/sim/providers/anthropic/usage.ts b/apps/sim/providers/anthropic/usage.ts new file mode 100644 index 00000000000..5f40f8b5408 --- /dev/null +++ b/apps/sim/providers/anthropic/usage.ts @@ -0,0 +1,145 @@ +import type { BlockTokens } from '@/executor/types' +import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy' +import type { ModelPricing } from '@/providers/types' + +export interface AnthropicUsageLike { + input_tokens?: number | null + output_tokens?: number | null + cache_read_input_tokens?: number | null + cache_creation_input_tokens?: number | null + cache_creation?: { + ephemeral_5m_input_tokens?: number | null + ephemeral_1h_input_tokens?: number | null + } | null +} + +export interface AnthropicUsageAccumulator { + input: number + output: number + cacheRead: number + cacheWriteFiveMinute: number + cacheWriteOneHour: number +} + +interface AnthropicUsageCost { + input: number + output: number + total: number + toolCost?: number + pricing: ModelPricing +} + +function tokenCount(value: number | null | undefined): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} + +function roundedCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Creates an empty accumulator for one Anthropic provider request. + */ +export function createAnthropicUsageAccumulator(): AnthropicUsageAccumulator { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWriteFiveMinute: 0, + cacheWriteOneHour: 0, + } +} + +/** + * Adds one Anthropic model response's usage without counting cache tokens as uncached input. + */ +export function addAnthropicUsage( + accumulator: AnthropicUsageAccumulator, + usage: AnthropicUsageLike | null | undefined +): void { + if (!usage) return + + accumulator.input += tokenCount(usage.input_tokens) + accumulator.output += tokenCount(usage.output_tokens) + accumulator.cacheRead += tokenCount(usage.cache_read_input_tokens) + + const cacheWriteTotal = tokenCount(usage.cache_creation_input_tokens) + if (!usage.cache_creation) { + accumulator.cacheWriteFiveMinute += cacheWriteTotal + return + } + + const fiveMinute = tokenCount(usage.cache_creation.ephemeral_5m_input_tokens) + const oneHour = tokenCount(usage.cache_creation.ephemeral_1h_input_tokens) + const detailedTotal = fiveMinute + oneHour + + accumulator.cacheWriteFiveMinute += fiveMinute + Math.max(0, cacheWriteTotal - detailedTotal) + accumulator.cacheWriteOneHour += oneHour +} + +/** + * Builds the block token shape, including cache reads and writes in the total. + */ +export function buildAnthropicUsageTokens( + accumulator: AnthropicUsageAccumulator +): Required> { + const cacheWrite = accumulator.cacheWriteFiveMinute + accumulator.cacheWriteOneHour + return { + input: accumulator.input, + output: accumulator.output, + total: accumulator.input + accumulator.output + accumulator.cacheRead + cacheWrite, + cacheRead: accumulator.cacheRead, + cacheWrite, + } +} + +/** 5-minute cache writes cost 1.25x the base input rate, 1-hour writes 2x. */ +const FIVE_MINUTE_WRITE_MULTIPLIER = 1.25 +const ONE_HOUR_WRITE_MULTIPLIER = 2 + +/** + * Builds the normalized usage for one Anthropic request. + * + * Anthropic reports `input_tokens` already excluding cache reads and writes + * (`total_input = cache_read + cache_creation + input_tokens`), so `input` maps + * across directly — unlike OpenAI and Gemini, whose cached counts are subsets + * of their prompt totals and must be subtracted. + */ +export function buildAnthropicModelUsage(accumulator: AnthropicUsageAccumulator): ModelUsage { + return { + input: accumulator.input, + output: accumulator.output, + cacheRead: accumulator.cacheRead, + cacheWrites: [ + { + tokens: accumulator.cacheWriteFiveMinute, + inputRateMultiplier: FIVE_MINUTE_WRITE_MULTIPLIER, + }, + { tokens: accumulator.cacheWriteOneHour, inputRateMultiplier: ONE_HOUR_WRITE_MULTIPLIER }, + ], + } +} + +/** + * Prices one Anthropic request, cache tiers included, through the shared + * pricing function. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function buildAnthropicUsageCost( + model: string, + accumulator: AnthropicUsageAccumulator, + toolCost = 0 +): AnthropicUsageCost { + const cost = priceModelUsage(model, buildAnthropicModelUsage(accumulator), LIST_PRICE_POLICY) + + return { + input: cost.input, + output: cost.output, + total: roundedCost(cost.total + toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: cost.pricing, + } +} diff --git a/apps/sim/providers/anthropic/utils.test.ts b/apps/sim/providers/anthropic/utils.test.ts new file mode 100644 index 00000000000..6c284067bdd --- /dev/null +++ b/apps/sim/providers/anthropic/utils.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + * + * Anthropic adapter emits AgentStreamEvent objects (thinking + text) + * from Messages stream fixtures; tool_use deltas are handled by the tool loop. + */ +import { describe, expect, it, vi } from 'vitest' +import { + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' +import { createReadableStreamFromAnthropicStream } from '@/providers/anthropic/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromAnthropicStream', () => { + it('emits thinking_delta then text_delta and ignores tool_use (thinking+text+tool fixture)', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicThinkingTextToolStreamEvents + })() as AsyncIterable, + onComplete + ) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should check the weather before answering. ', + 'Calling get_weather for SF.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { + type: 'text_delta', + text: anthropicThinkingTextToolExpectedText, + turn: 'final', + }, + ]) + expect(events.some((e) => e.type === 'tool_call_start' || e.type === 'tool_call_end')).toBe( + false + ) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: anthropicThinkingTextToolExpectedText, + thinking: anthropicThinkingTextToolExpectedThinking, + usage: { input: 42, output: expect.any(Number) }, + }) + }) + + it('captures stream cache usage once from cumulative message events', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield { + type: 'message_start', + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + cache_creation: { + ephemeral_5m_input_tokens: 10, + ephemeral_1h_input_tokens: 20, + }, + }, + }, + } + yield { + type: 'message_delta', + usage: { + input_tokens: 10, + output_tokens: 40, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + }, + } + })() as AsyncIterable, + onComplete + ) + + await collectEvents(stream) + + expect(onComplete.mock.calls[0][0].usage).toEqual({ + input: 10, + output: 40, + cacheRead: 20, + cacheWriteFiveMinute: 10, + cacheWriteOneHour: 20, + }) + }) + + it('records [redacted] for redacted_thinking blocks and streams text', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicRedactedThinkingStreamEvents + })() as AsyncIterable, + onComplete + ) + + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'Visible follow-up reasoning after redaction.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { + type: 'text_delta', + text: anthropicRedactedThinkingExpectedText, + turn: 'final', + }, + ]) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: anthropicRedactedThinkingExpectedText, + thinking: anthropicRedactedThinkingExpectedTraceThinking, + }) + }) + + it('errors the readable stream when the source throws', async () => { + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'partial' }, + } + throw new Error('provider reset') + })() as AsyncIterable + ) + + await expect(collectEvents(stream)).rejects.toThrow('provider reset') + }) +}) diff --git a/apps/sim/providers/anthropic/utils.ts b/apps/sim/providers/anthropic/utils.ts index b9b001bb7a2..ad6ac2958f9 100644 --- a/apps/sim/providers/anthropic/utils.ts +++ b/apps/sim/providers/anthropic/utils.ts @@ -1,62 +1,136 @@ -import type { - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStreamEvent, - Usage, -} from '@anthropic-ai/sdk/resources' +import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import { createLogger } from '@sim/logger' -import { randomFloat } from '@sim/utils/random' +import { + type AnthropicUsageAccumulator, + type AnthropicUsageLike, + addAnthropicUsage, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' +import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('AnthropicUtils') -export interface AnthropicStreamUsage { - input_tokens: number - output_tokens: number +export interface AnthropicStreamComplete { + content: string + usage: AnthropicUsageAccumulator + /** Assembled thinking text for traces (redacted blocks become `[redacted]`). */ + thinking: string } +/** + * Converts an Anthropic Messages stream into an in-process + * {@link AgentStreamEvent} object stream (`thinking_delta` + `text_delta`). + * Tool_use / input_json deltas are ignored here — use + * {@link createAnthropicStreamingToolLoopStream} for the live tool loop. + */ export function createReadableStreamFromAnthropicStream( anthropicStream: AsyncIterable, - onComplete?: (content: string, usage: AnthropicStreamUsage) => void -): ReadableStream { - let fullContent = '' - let inputTokens = 0 - let outputTokens = 0 + onComplete?: (result: AnthropicStreamComplete) => void +): ReadableStream { + let cancelled = false + let streamIterator: AsyncIterator | undefined - return new ReadableStream({ + return new ReadableStream({ async start(controller) { try { - for await (const event of anthropicStream) { + let fullContent = '' + const thinkingBlocks: string[] = [] + let currentThinking = '' + let usageSnapshot: AnthropicUsageLike = {} + + const flushThinkingBlock = () => { + if (currentThinking) { + thinkingBlocks.push(currentThinking) + currentThinking = '' + } + } + + streamIterator = anthropicStream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const event = next.value if (event.type === 'message_start') { - const startEvent = event as RawMessageStartEvent - const usage: Usage = startEvent.message.usage - inputTokens = usage.input_tokens - } else if (event.type === 'message_delta') { - const deltaEvent = event as RawMessageDeltaEvent - outputTokens = deltaEvent.usage.output_tokens - } else if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { - const text = event.delta.text - fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + usageSnapshot = event.message.usage + continue + } + + if (event.type === 'message_delta') { + usageSnapshot = { + ...usageSnapshot, + input_tokens: event.usage.input_tokens ?? usageSnapshot.input_tokens, + output_tokens: event.usage.output_tokens ?? usageSnapshot.output_tokens, + cache_read_input_tokens: + event.usage.cache_read_input_tokens ?? usageSnapshot.cache_read_input_tokens, + cache_creation_input_tokens: + event.usage.cache_creation_input_tokens ?? + usageSnapshot.cache_creation_input_tokens, + } + continue + } + + if (event.type === 'content_block_start') { + if (event.content_block.type === 'redacted_thinking') { + flushThinkingBlock() + thinkingBlocks.push('[redacted]') + } else if (event.content_block.type === 'thinking') { + flushThinkingBlock() + } + continue + } + + if (event.type === 'content_block_stop') { + flushThinkingBlock() + continue + } + + if (event.type !== 'content_block_delta') { + continue + } + + const delta = event.delta + + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + currentThinking += delta.thinking + controller.enqueue({ type: 'thinking_delta', text: delta.thinking }) + continue + } + + if (delta.type === 'text_delta' && typeof delta.text === 'string') { + flushThinkingBlock() + fullContent += delta.text + controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'final' }) } } + if (cancelled) return + flushThinkingBlock() + if (onComplete) { - onComplete(fullContent, { input_tokens: inputTokens, output_tokens: outputTokens }) + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, usageSnapshot) + onComplete({ + content: fullContent, + usage, + thinking: thinkingBlocks.filter(Boolean).join('\n\n'), + }) } controller.close() } catch (err) { - controller.error(err) + if (!cancelled) { + controller.error(err) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } -export function generateToolUseId(toolName: string): string { - return `${toolName}-${Date.now()}-${randomFloat().toString(36).substring(2, 7)}` -} - export function checkForForcedToolUsage( response: any, toolChoice: any, diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts index 0956aa0dac8..6ca390ca2b9 100644 --- a/apps/sim/providers/azure-anthropic/index.test.ts +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -56,7 +56,7 @@ function request(overrides: Partial): ProviderRequest { /** Invokes the createClient factory handed to the Anthropic core and returns the SDK options it built. */ function buildClientOptions(): Record { const config = mockExecuteAnthropic.mock.calls[0][1] - config.createClient('k', false) + config.createClient('k') return anthropicArgs[0] } @@ -92,6 +92,18 @@ describe('azureAnthropicProvider — SSRF pinning', () => { expect(buildClientOptions()).not.toHaveProperty('fetch') }) + it('keeps the registry model in core and resolves a separate Azure wire model', async () => { + setEnv({ AZURE_ANTHROPIC_ENDPOINT: 'https://identity.services.ai.azure.com' }) + const providerRequest = request({}) + + await azureAnthropicProvider.executeRequest(providerRequest) + + const [forwardedRequest, config] = mockExecuteAnthropic.mock.calls[0] + expect(forwardedRequest.model).toBe('azure-anthropic/claude-3-5-sonnet') + expect(config.resolveWireModel(forwardedRequest)).toBe('claude-3-5-sonnet') + expect(buildClientOptions().defaultHeaders).not.toHaveProperty('anthropic-beta') + }) + it('throws and never builds a client when validation blocks the endpoint', async () => { mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) diff --git a/apps/sim/providers/azure-anthropic/index.ts b/apps/sim/providers/azure-anthropic/index.ts index 2f0498992a0..fe7881755db 100644 --- a/apps/sim/providers/azure-anthropic/index.ts +++ b/apps/sim/providers/azure-anthropic/index.ts @@ -52,53 +52,40 @@ export const azureAnthropicProvider: ProviderConfig = { throw new Error('API key is required for Azure Anthropic.') } - // Strip the azure-anthropic/ prefix from the model name if present - const modelName = request.model.replace(/^azure-anthropic\//, '') - - // Azure AI Foundry hosts Anthropic models at {endpoint}/anthropic - // The SDK appends /v1/messages automatically - const baseURL = `${azureEndpoint.replace(/\/$/, '')}/anthropic` + const normalizedEndpoint = azureEndpoint.replace(/\/$/, '') + const baseURL = normalizedEndpoint.endsWith('/anthropic') + ? normalizedEndpoint + : `${normalizedEndpoint}/anthropic` const anthropicVersion = request.azureApiVersion || env.AZURE_ANTHROPIC_API_VERSION || '2023-06-01' - return executeAnthropicProviderRequest( - { - ...request, - model: modelName, - apiKey, + return executeAnthropicProviderRequest(request, { + providerId: 'azure-anthropic', + providerLabel: 'Azure Anthropic', + resolveWireModel: ({ model }) => model.replace(/^azure-anthropic\//, ''), + createClient: (apiKey) => { + const cacheKey = [ + 'azure-anthropic', + apiKey, + baseURL, + anthropicVersion, + pinnedIP ?? 'no-pin', + ].join('::') + return getCachedProviderClient( + cacheKey, + () => + new Anthropic({ + baseURL, + apiKey, + ...(pinnedFetch ? { fetch: pinnedFetch } : {}), + defaultHeaders: { + 'anthropic-version': anthropicVersion, + }, + }) + ) }, - { - providerId: 'azure-anthropic', - providerLabel: 'Azure Anthropic', - createClient: (apiKey, useNativeStructuredOutputs) => { - const cacheKey = [ - 'azure-anthropic', - apiKey, - baseURL, - anthropicVersion, - pinnedIP ?? 'no-pin', - useNativeStructuredOutputs ? 'beta' : 'default', - ].join('::') - return getCachedProviderClient( - cacheKey, - () => - new Anthropic({ - baseURL, - apiKey, - ...(pinnedFetch ? { fetch: pinnedFetch } : {}), - defaultHeaders: { - 'api-key': apiKey, - 'anthropic-version': anthropicVersion, - ...(useNativeStructuredOutputs - ? { 'anthropic-beta': 'structured-outputs-2025-11-13' } - : {}), - }, - }) - ) - }, - logger, - } - ) + logger, + }) }, } diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index f58c72ddfd6..44310504746 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -3,7 +3,8 @@ */ import { resetEnvMock, setEnv } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProviderRequest } from '@/providers/types' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderRequest, ProviderToolConfig } from '@/providers/types' const { mockAzureOpenAI, @@ -15,6 +16,8 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint, mockIsResponsesEndpoint, + mockPrepareTools, + mockExecuteTool, } = vi.hoisted(() => { const azureOpenAIArgs: Array> = [] const sentinelFetch = vi.fn() @@ -35,6 +38,8 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint: vi.fn(() => false), mockIsResponsesEndpoint: vi.fn(() => false), + mockPrepareTools: vi.fn(), + mockExecuteTool: vi.fn(), } }) @@ -73,14 +78,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), - prepareToolsWithUsageControl: vi.fn(() => ({ - tools: [], - toolChoice: undefined, - forcedTools: [], - })), + prepareToolsWithUsageControl: mockPrepareTools, sumToolCosts: vi.fn(() => 0), })) -vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { azureOpenAIProvider } from '@/providers/azure-openai/index' @@ -88,6 +89,26 @@ function request(overrides: Partial): ProviderRequest { return { model: 'azure/gpt-4o', apiKey: 'k', messages: [], ...overrides } } +function makeTool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + /** Config object passed to the Responses core on the Nth call. */ const responsesConfig = (call = 0) => mockExecuteResponses.mock.calls[call][1] @@ -101,6 +122,12 @@ describe('azureOpenAIProvider — SSRF pinning', () => { mockIsChatCompletionsEndpoint.mockReturnValue(false) mockIsResponsesEndpoint.mockReturnValue(false) mockExecuteResponses.mockResolvedValue({ content: 'ok' }) + mockPrepareTools.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + }) + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) describe('Responses API path', () => { @@ -188,5 +215,61 @@ describe('azureOpenAIProvider — SSRF pinning', () => { expect(mockCreatePinnedFetch).not.toHaveBeenCalled() expect(azureOpenAIArgs[0]).not.toHaveProperty('fetch') }) + + it('projects the settled tool-loop answer without a final streaming request', async () => { + mockIsChatCompletionsEndpoint.mockReturnValue(true) + mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + mockPrepareTools.mockReturnValue({ + tools: [{ type: 'function', function: { name: 'lookup' } }], + toolChoice: 'auto', + forcedTools: [], + }) + mockChatCreate + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'done', tool_calls: undefined } }], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }) + + const result = await azureOpenAIProvider.executeRequest( + request({ + azureEndpoint: 'https://rebind.attacker.tld/openai/deployments/gpt-4o/chat/completions', + stream: true, + tools: [makeTool('lookup')], + }) + ) + + expect(mockChatCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 6, output: 3, total: 9 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'done', turn: 'final' }]) + }) }) }) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index e9c7cfefb4b..14c83fdd34c 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { AzureOpenAI } from 'openai' import type { ChatCompletion, @@ -27,7 +28,9 @@ import { } from '@/providers/azure-openai/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeResponsesProviderRequest } from '@/providers/openai/core' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -201,6 +204,7 @@ async function executeChatCompletionsRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -301,10 +305,25 @@ async function executeChatCompletionsRequest( const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -322,6 +341,9 @@ async function executeChatCompletionsRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -341,7 +363,7 @@ async function executeChatCompletionsRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -356,11 +378,9 @@ async function executeChatCompletionsRequest( })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -370,10 +390,12 @@ async function executeChatCompletionsRequest( duration: duration, }) - let resultContent: Record + let resultContent: unknown if (result.success) { - toolResults.push(result.output as Record) - resultContent = result.output as Record + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -471,24 +493,56 @@ async function executeChatCompletionsRequest( iterationCount++ } - if (request.stream) { - logger.info('Using streaming for final response after tool processing') + if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { + /** + * The capped turn still requests tools, so make one tool-disabled call to + * synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = (await azureOpenAI.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + )) as ChatCompletion + const synthesisEndTime = Date.now() - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'auto', - stream: true, - stream_options: { include_usage: true }, + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 } - const streamResponse = await azureOpenAI.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'azure_openai' } ) + } - const streamingResult = createStreamingExecution({ + if (request.stream) { + logger.info('Projecting settled response after tool processing') + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -497,7 +551,7 @@ async function executeChatCompletionsRequest( modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -508,7 +562,8 @@ async function executeChatCompletionsRequest( initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -517,33 +572,13 @@ async function executeChatCompletionsRequest( count: toolCalls.length, } : undefined, - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - - finalizeTiming() - }), + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -563,7 +598,7 @@ async function executeChatCompletionsRequest( modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -577,6 +612,10 @@ async function executeChatCompletionsRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/azure-openai/utils.ts b/apps/sim/providers/azure-openai/utils.ts index fec1e862e51..08a84c0209d 100644 --- a/apps/sim/providers/azure-openai/utils.ts +++ b/apps/sim/providers/azure-openai/utils.ts @@ -3,17 +3,24 @@ import type OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import type { Stream } from 'openai/streaming' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from an Azure OpenAI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Azure OpenAI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromAzureOpenAIStream( azureOpenAIStream: Stream, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(azureOpenAIStream, 'Azure OpenAI', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(azureOpenAIStream, { + providerName: 'Azure OpenAI', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index df296c6626e..d9e450ece2f 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/baseten/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,16 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = ( + assistant: { content?: string | null; reasoning_content?: string } = {} +) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning_content !== undefined + ? { reasoning_content: assistant.reasoning_content } + : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -224,15 +232,54 @@ describe('basetenProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Baseten assistant content and reasoning_content on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await basetenProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await basetenProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await basetenProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index a25fb29cb9c..879efabd697 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { supportsNativeStructuredOutputs, } from '@/providers/baseten/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -167,6 +171,7 @@ export const basetenProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -262,10 +267,25 @@ export const basetenProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -283,6 +303,9 @@ export const basetenProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Baseten):', { error: toError(error).message, @@ -305,26 +328,21 @@ export const basetenProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -335,10 +353,12 @@ export const basetenProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -416,84 +436,61 @@ export const basetenProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'baseten' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'baseten', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'baseten' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -549,6 +546,45 @@ export const basetenProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -566,7 +602,7 @@ export const basetenProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -589,6 +625,10 @@ export const basetenProvider: ProviderConfig = { } logger.error('Error in Baseten request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/baseten/utils.ts b/apps/sim/providers/baseten/utils.ts index ca5cf5dc5c0..d277f41a2c9 100644 --- a/apps/sim/providers/baseten/utils.ts +++ b/apps/sim/providers/baseten/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Checks if a model supports native structured outputs (json_schema). @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Baseten streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Baseten streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Baseten', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Baseten', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index 3a9abceefbd..17d593d048c 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -22,6 +22,9 @@ vi.mock('@/providers/bedrock/utils', () => ({ checkForForcedToolUsage: vi.fn(), createReadableStreamFromBedrockStream: vi.fn(), generateToolUseId: vi.fn().mockReturnValue('tool-1'), + getBedrockStreamError: vi.fn().mockReturnValue(null), + // The mocked inference profile above is a Claude model, which supports it. + supportsToolResultStatus: vi.fn().mockReturnValue(true), })) vi.mock('@/providers/models', () => ({ @@ -31,11 +34,15 @@ vi.mock('@/providers/models', () => ({ INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'), + supportsNativeStructuredOutputs: vi.fn().mockReturnValue(false), })) vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0, pricing: null }), - prepareToolExecution: vi.fn(), + prepareToolExecution: vi.fn((_tool, args) => ({ + toolParams: args, + executionParams: args, + })), prepareToolsWithUsageControl: vi.fn().mockReturnValue({ tools: [], toolChoice: 'auto', @@ -45,12 +52,14 @@ vi.mock('@/providers/utils', () => ({ })) vi.mock('@/tools', () => ({ - executeTool: vi.fn(), + executeTool: vi.fn().mockResolvedValue({ success: true, output: false }), })) -import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime' +import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime' +import type { StreamingExecution } from '@/executor/types' import { bedrockProvider } from '@/providers/bedrock/index' import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import { prepareToolsWithUsageControl } from '@/providers/utils' describe('bedrockProvider credential handling', () => { beforeEach(() => { @@ -120,4 +129,188 @@ describe('bedrockProvider credential handling', () => { region: 'eu-west-1', }) }) + + it('uses the live loop for streaming tool requests without a caller flag', async () => { + vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ + tools: [ + { + name: 'lookup', + description: 'Lookup', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + mockSend + .mockResolvedValueOnce({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { + toolUse: { + toolUseId: 'tool-1', + name: 'lookup', + }, + }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{}' } }, + }, + } + yield { metadata: { usage: { inputTokens: 1, outputTokens: 1 } } } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + }) + .mockResolvedValueOnce({ + stream: (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'settled answer' }, + }, + } + yield { metadata: { usage: { inputTokens: 2, outputTokens: 2 } } } + yield { messageStop: { stopReason: 'end_turn' } } + })(), + }) + + const result = (await bedrockProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + })) as StreamingExecution + + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + + expect(mockSend).toHaveBeenCalledTimes(2) + expect(result.execution.output.content).toBe('settled answer') + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + }) + + it('keeps the explicit structured-output extraction call before settled projection', async () => { + vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ + tools: [ + { + name: 'lookup', + description: 'Lookup', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + mockSend + .mockResolvedValueOnce({ + output: { + message: { + content: [ + { + toolUse: { + toolUseId: 'tool-1', + name: 'lookup', + input: {}, + }, + }, + ], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 1, outputTokens: 1 }, + }) + .mockResolvedValueOnce({ + output: { message: { content: [{ text: 'unformatted answer' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 2, outputTokens: 2 }, + }) + .mockResolvedValueOnce({ + output: { + message: { + content: [ + { + toolUse: { + toolUseId: 'structured-1', + name: 'structured_output', + input: { answer: 'formatted' }, + }, + }, + ], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 3, outputTokens: 3 }, + }) + + const result = (await bedrockProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + }, + }, + })) as StreamingExecution + + expect(mockSend).toHaveBeenCalledTimes(3) + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + expect(vi.mocked(ConverseCommand).mock.calls[2][0]).toMatchObject({ + toolConfig: { + tools: [ + { + toolSpec: { + name: 'structured_output', + }, + }, + ], + toolChoice: { tool: { name: 'structured_output' } }, + }, + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { + type: 'text_delta', + text: '{\n "answer": "formatted"\n}', + turn: 'final', + }, + }) + }) }) diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 4e512d15b48..544d823209b 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -7,6 +7,7 @@ import { ConverseCommand, type ConverseResponse, ConverseStreamCommand, + type OutputConfig, type SystemContentBlock, type Tool, type ToolConfiguration, @@ -15,18 +16,27 @@ import { } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { buildBedrockMessageContent } from '@/providers/attachments' +import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import { checkForForcedToolUsage, createReadableStreamFromBedrockStream, generateToolUseId, getBedrockInferenceProfileId, + supportsToolResultStatus, } from '@/providers/bedrock/utils' import { getCachedProviderClient } from '@/providers/client-cache' -import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + getProviderDefaultModel, + getProviderModels, + supportsNativeStructuredOutputs, +} from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -181,7 +191,7 @@ export const bedrockProvider: ProviderConfig = { const toolUseBlock: ToolUseBlock = { toolUseId: msg.tool_calls?.[0]?.id || generateToolUseId(toolCall.name), name: toolCall.name, - input: JSON.parse(toolCall.arguments), + input: parseToolArguments(toolCall.arguments, toolCall.name) as ToolUseBlock['input'], } messages.push({ role: 'assistant' as ConversationRole, @@ -209,23 +219,38 @@ export const bedrockProvider: ProviderConfig = { } let structuredOutputTool: Tool | undefined + let outputConfig: OutputConfig | undefined const structuredOutputToolName = 'structured_output' if (request.responseFormat) { const schema = request.responseFormat.schema || request.responseFormat const schemaName = request.responseFormat.name || 'response' - structuredOutputTool = { - toolSpec: { - name: structuredOutputToolName, - description: `Output the response as structured JSON matching the ${schemaName} schema. You MUST call this tool to provide your final response.`, - inputSchema: { - json: schema, + if (supportsNativeStructuredOutputs(request.model) && !request.tools?.length) { + outputConfig = { + textFormat: { + type: 'json_schema', + structure: { + jsonSchema: { + name: schemaName, + schema: JSON.stringify(schema), + }, + }, }, - }, + } + logger.info(`Using native structured outputs: ${schemaName}`) + } else { + structuredOutputTool = { + toolSpec: { + name: structuredOutputToolName, + description: `Output the response as structured JSON matching the ${schemaName} schema. You MUST call this tool to provide your final response.`, + inputSchema: { + json: schema, + }, + }, + } + logger.info(`Using tool-based structured outputs: ${schemaName}`) } - - logger.info(`Using Tool Use approach for structured outputs: ${schemaName}`) } let bedrockTools: Tool[] | undefined @@ -247,52 +272,56 @@ export const bedrockProvider: ProviderConfig = { }, })) - try { - preparedTools = prepareToolsWithUsageControl( - bedrockTools.map((t) => ({ - name: t.toolSpec?.name || '', - description: t.toolSpec?.description || '', - input_schema: t.toolSpec?.inputSchema?.json, - })), - request.tools, - logger, - 'bedrock' - ) - - const { tools: filteredTools, toolChoice: tc } = preparedTools + preparedTools = prepareToolsWithUsageControl( + bedrockTools.map((t) => ({ + name: t.toolSpec?.name || '', + description: t.toolSpec?.description || '', + input_schema: t.toolSpec?.inputSchema?.json, + })), + request.tools, + logger, + 'bedrock' + ) - if (filteredTools?.length) { - bedrockTools = filteredTools.map((t: any) => ({ + const { tools: filteredTools, toolChoice: preparedToolChoice } = preparedTools + bedrockTools = filteredTools?.length + ? filteredTools.map((tool) => ({ toolSpec: { - name: t.name, - description: t.description, - inputSchema: { json: t.input_schema }, + name: tool.name, + description: tool.description, + inputSchema: { json: tool.input_schema }, }, })) + : undefined - if (typeof tc === 'object' && tc !== null) { - if (tc.type === 'tool' && tc.name) { - toolChoice = { tool: { name: tc.name } } - logger.info(`Using Bedrock tool_choice format: force tool "${tc.name}"`) - } else if (tc.type === 'function' && tc.function?.name) { - toolChoice = { tool: { name: tc.function.name } } - logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`) - } else if (tc.type === 'any') { - toolChoice = { any: {} } - logger.info('Using Bedrock tool_choice format: any tool') - } else { - toolChoice = { auto: {} } - } - } else if (tc === 'none') { - toolChoice = undefined - bedrockTools = undefined - } else { - toolChoice = { auto: {} } - } + if (bedrockTools?.length) { + if (preparedToolChoice === 'auto') { + toolChoice = { auto: {} } + } else if (preparedToolChoice === 'none') { + toolChoice = undefined + bedrockTools = undefined + } else if ( + preparedToolChoice?.type === 'tool' && + typeof preparedToolChoice.name === 'string' && + preparedToolChoice.name.length > 0 + ) { + toolChoice = { tool: { name: preparedToolChoice.name } } + logger.info(`Using Bedrock tool_choice format: force tool "${preparedToolChoice.name}"`) + } else if ( + preparedToolChoice?.type === 'function' && + typeof preparedToolChoice.function?.name === 'string' && + preparedToolChoice.function.name.length > 0 + ) { + toolChoice = { tool: { name: preparedToolChoice.function.name } } + logger.info( + `Using Bedrock tool_choice format: force tool "${preparedToolChoice.function.name}"` + ) + } else if (preparedToolChoice?.type === 'any') { + toolChoice = { any: {} } + logger.info('Using Bedrock tool_choice format: any tool') + } else { + throw new Error('Invalid Bedrock tool choice returned by tool preparation') } - } catch (error) { - logger.error('Error in prepareToolsWithUsageControl:', { error }) - toolChoice = { auto: {} } } } else if (structuredOutputTool) { bedrockTools = [structuredOutputTool] @@ -347,7 +376,66 @@ export const bedrockProvider: ProviderConfig = { inferenceConfig.maxTokens = Number.parseInt(String(request.maxTokens)) } - const shouldStreamToolCalls = request.streamToolCalls ?? false + /** + * The live tool loop cannot honor responseFormat — structured output on + * Bedrock rides a final forced `structured_output` tool call that only the + * silent loop performs — so those requests fall back to the silent path. + */ + const liveToolLoopSupported = !request.responseFormat + + if (request.stream && liveToolLoopSupported && bedrockTools && bedrockTools.length > 0) { + logger.info('Using streaming tool loop for Bedrock request') + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createBedrockStreamingToolLoopStream({ + client, + modelId: bedrockModelId, + request, + messages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + bedrockTools, + toolChoice, + logger, + timeSegments, + forcedTools, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } if (request.stream && (!bedrockTools || bedrockTools.length === 0)) { logger.info('Using streaming response for Bedrock request (no tools)') @@ -360,6 +448,7 @@ export const bedrockProvider: ProviderConfig = { messages, system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, inferenceConfig, + outputConfig, }) const streamResponse = await client.send( @@ -380,6 +469,7 @@ export const bedrockProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { total: 0.0, input: 0.0, output: 0.0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromBedrockStream(bedrockStream, (content, usage) => { output.content = content @@ -417,6 +507,7 @@ export const bedrockProvider: ProviderConfig = { messages, system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, inferenceConfig, + outputConfig, toolConfig, }) @@ -427,7 +518,6 @@ export const bedrockProvider: ProviderConfig = { const firstResponseTime = Date.now() - initialCallTime let content = '' - let hasExtractedStructuredOutput = false if (currentResponse.output?.message?.content) { const structuredOutputCall = currentResponse.output.message.content.find( (block): block is ContentBlock & { toolUse: ToolUseBlock } => @@ -436,7 +526,6 @@ export const bedrockProvider: ProviderConfig = { if (structuredOutputCall && structuredOutputTool) { content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2) - hasExtractedStructuredOutput = true logger.info('Extracted structured output from tool call') } else { const textBlocks = currentResponse.output.message.content.filter( @@ -520,7 +609,12 @@ export const bedrockProvider: ProviderConfig = { ) const currentToolUses = toolUseContentBlocks.map((block) => block.toolUse) - if (!currentToolUses || currentToolUses.length === 0) { + if (currentToolUses.length > 0 && currentResponse.stopReason !== 'tool_use') { + throw new Error( + `Bedrock returned tool use with stop reason ${currentResponse.stopReason ?? 'missing'}` + ) + } + if (currentToolUses.length === 0) { break } @@ -529,12 +623,35 @@ export const bedrockProvider: ProviderConfig = { const toolExecutionPromises = currentToolUses.map(async (toolUse: ToolUseBlock) => { const toolCallStartTime = Date.now() const toolName = toolUse.name || '' - const toolArgs = (toolUse.input as Record) || {} + const toolArgs = + toolUse.input && typeof toolUse.input === 'object' && !Array.isArray(toolUse.input) + ? (toolUse.input as Record) + : undefined const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) try { + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolUseId, + toolName, + toolArgs, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -545,7 +662,7 @@ export const bedrockProvider: ProviderConfig = { return { toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams, result, startTime: toolCallStartTime, @@ -553,6 +670,9 @@ export const bedrockProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -573,15 +693,13 @@ export const bedrockProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) - const assistantContent: ContentBlock[] = currentToolUses.map((toolUse: ToolUseBlock) => ({ - toolUse: { - toolUseId: toolUse.toolUseId, - name: toolUse.name, - input: toolUse.input, - }, - })) + // Bedrock rejects a blank text block on replay even when it produced + // one itself, so drop whitespace-only text while echoing the rest. + const assistantContent: ContentBlock[] = ( + currentResponse.output?.message?.content ?? [] + ).filter((block) => !('text' in block) || Boolean(block.text?.trim())) currentMessages.push({ role: 'assistant' as ConversationRole, content: assistantContent, @@ -589,9 +707,7 @@ export const bedrockProvider: ProviderConfig = { const toolResultContent: ContentBlock[] = [] - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolUseId, toolName, @@ -601,7 +717,7 @@ export const bedrockProvider: ProviderConfig = { startTime, endTime, duration, - } = settledResult.value + } = executionResult timeSegments.push({ type: 'tool', @@ -611,10 +727,12 @@ export const bedrockProvider: ProviderConfig = { duration, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -636,6 +754,9 @@ export const bedrockProvider: ProviderConfig = { const toolResultBlock: ToolResultBlock = { toolUseId, content: [{ text: JSON.stringify(resultContent) }], + ...(supportsToolResultStatus(bedrockModelId) + ? { status: result.success ? 'success' : 'error' } + : {}), } toolResultContent.push({ toolResult: toolResultBlock }) } @@ -780,7 +901,6 @@ export const bedrockProvider: ProviderConfig = { if (structuredOutputCall) { content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2) - hasExtractedStructuredOutput = true logger.info('Extracted structured output from forced tool call') } else { logger.warn('Structured output tool was forced but no tool call found in response') @@ -808,54 +928,9 @@ export const bedrockProvider: ProviderConfig = { const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - if (request.stream && !shouldStreamToolCalls && !hasExtractedStructuredOutput) { - logger.info('Using streaming for final Bedrock response after tool processing') - - const messagesHaveToolContent = currentMessages.some((msg) => - msg.content?.some( - (block) => - ('toolUse' in block && block.toolUse) || ('toolResult' in block && block.toolResult) - ) - ) - - const streamToolConfig: ToolConfiguration | undefined = - messagesHaveToolContent && request.tools?.length - ? { - tools: request.tools.map((tool) => ({ - toolSpec: { - name: tool.id, - description: tool.description, - inputSchema: { - json: { - type: 'object', - properties: tool.parameters.properties, - required: tool.parameters.required, - }, - }, - }, - })), - toolChoice: { auto: {} }, - } - : undefined - - const streamCommand = new ConverseStreamCommand({ - modelId: bedrockModelId, - messages: currentMessages, - system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, - inferenceConfig, - toolConfig: streamToolConfig, - }) - - const streamResponse = await client.send( - streamCommand, - request.abortSignal ? { abortSignal: request.abortSignal } : undefined - ) - - if (!streamResponse.stream) { - throw new Error('No stream returned from Bedrock') - } - - const bedrockStream = streamResponse.stream + if (request.stream && !liveToolLoopSupported) { + logger.info('Projecting settled Bedrock response after tool processing') + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, providerStartTime, @@ -865,39 +940,25 @@ export const bedrockProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, initialCost: { input: cost.input, output: cost.output, - toolCost: undefined as number | undefined, - total: cost.total, + toolCost: toolCost || undefined, + total: cost.total + toolCost, }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => { - output.content = streamContent - output.tokens = { - input: tokens.input + usage.inputTokens, - output: tokens.output + usage.outputTokens, - total: tokens.total + usage.inputTokens + usage.outputTokens, - } - - const streamCost = calculateCost(request.model, usage.inputTokens, usage.outputTokens) - const tc = sumToolCosts(toolResults) - output.cost = { - input: cost.input + streamCost.input, - output: cost.output + streamCost.output, - toolCost: tc || undefined, - total: cost.total + streamCost.total + tc, - } - - finalizeTiming() - }), + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -932,7 +993,7 @@ export const bedrockProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, } @@ -946,6 +1007,10 @@ export const bedrockProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..cf18f0e334c --- /dev/null +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -0,0 +1,347 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: vi.fn(() => ({ + toolParams: { url: 'https://example.com' }, + executionParams: { url: 'https://example.com' }, + })), + calculateCost: vi.fn(() => ({ + input: 0.01, + output: 0.02, + total: 0.03, + pricing: { input: 1, output: 2, updatedAt: new Date().toISOString() }, + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), +})) + +describe('createBedrockStreamingToolLoopStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ + success: true, + output: { ok: true }, + }) + }) + + it('emits tool_call_start/end and final text; no invented thinking', async () => { + const turns = [ + (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":"https://example.com"}' } }, + }, + } + yield { + metadata: { usage: { inputTokens: 11, outputTokens: 4 } }, + } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'Request completed.' }, + }, + } + yield { + metadata: { usage: { inputTokens: 22, outputTokens: 6 } }, + } + yield { messageStop: { stopReason: 'end_turn' } } + })(), + ] + + let turnIdx = 0 + const client = { + send: vi.fn(async () => ({ stream: turns[turnIdx++] })), + } + + const onComplete = vi.fn() + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'call it' }] }], + inferenceConfig: { temperature: 0.7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect(events.filter((e) => e.type === 'tool_call_start')).toEqual([ + { type: 'tool_call_start', id: 'tooluse_1', name: 'http_request' }, + ]) + expect(events.filter((e) => e.type === 'tool_call_end')).toEqual([ + { type: 'tool_call_end', id: 'tooluse_1', name: 'http_request', status: 'success' }, + ]) + // Text streams live as `pending`; the turn_end sequence classifies turns. + expect( + events + .filter((e) => e.type === 'text_delta' && e.turn === 'pending') + .map((e) => e.text) + .join('') + ).toBe('Request completed.') + expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ + 'intermediate', + 'final', + ]) + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Request completed.', + toolCalls: expect.objectContaining({ count: 1 }), + }) + ) + }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":"https://example.com"}' } }, + }, + } + yield { metadata: { usage: { inputTokens: 11, outputTokens: 4 } } } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + })), + } + const onComplete = vi.fn() + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'call it' }] }], + inferenceConfig: { temperature: 0.7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 11, output: 4, total: 15 } }) + ) + }) + + it('finalizes truncated text when max_tokens is reached without a tool call', async () => { + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'Truncated answer' }, + }, + } + yield { metadata: { usage: { inputTokens: 13, outputTokens: 7 } } } + yield { messageStop: { stopReason: 'max_tokens' } } + })(), + })), + } + const onComplete = vi.fn() + const timeSegments: Array<{ type: string }> = [] + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + } as any, + messages: [{ role: 'user', content: [{ text: 'answer' }] }], + inferenceConfig: { temperature: 0.7, maxTokens: 7 }, + bedrockTools: [], + toolChoice: undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: timeSegments as any, + onComplete, + }) + + await expect(collectEvents(stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Truncated answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Truncated answer', + tokens: { input: 13, output: 7, total: 20 }, + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'max_tokens', + assistantContent: 'Truncated answer', + }) + }) + + it('rejects a max_tokens turn containing a partial tool call', async () => { + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_partial', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":' } }, + }, + } + yield { metadata: { usage: { inputTokens: 13, outputTokens: 7 } } } + yield { messageStop: { stopReason: 'max_tokens' } } + })(), + })), + } + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'answer' }] }], + inferenceConfig: { temperature: 0.7, maxTokens: 7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toMatchObject({ + message: 'Bedrock returned tool use with stop reason max_tokens', + }) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'tooluse_partial', + name: 'http_request', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'tooluse_partial', + name: 'http_request', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts new file mode 100644 index 00000000000..daae801472c --- /dev/null +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -0,0 +1,613 @@ +/** + * Live Bedrock ConverseStream tool loop. + * + * Capability-honest: text + tool_call_start/end only — Sim does not request + * Bedrock reasoning, so no thinking is invented. Text emits live as `pending` + * deltas and a `turn_end` event classifies each turn, so the pump projects + * only final-turn text to the answer channel. Abort → cancelled. + */ + +import { + type Message as BedrockMessage, + type BedrockRuntimeClient, + type ContentBlock, + type ConversationRole, + ConverseStreamCommand, + type SystemContentBlock, + type Tool, + type ToolConfiguration, + type ToolResultBlock, + type ToolUseBlock, +} from '@aws-sdk/client-bedrock-runtime' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + checkForForcedToolUsage, + generateToolUseId, + getBedrockStreamError, + supportsToolResultStatus, +} from '@/providers/bedrock/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + type StreamingToolLoopComplete, + settleOpenTools, + terminateToolLoop, +} from '@/providers/streaming-tool-loop-shared' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { calculateCost, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' + +export interface CreateBedrockStreamingToolLoopStreamOptions { + client: BedrockRuntimeClient + modelId: string + request: ProviderRequest + messages: BedrockMessage[] + system?: SystemContentBlock[] + inferenceConfig: { temperature: number; maxTokens?: number } + bedrockTools: Tool[] + toolChoice: ToolConfiguration['toolChoice'] + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + onComplete: (result: StreamingToolLoopComplete) => void +} + +interface AssembledToolUse { + toolUseId: string + name: string + inputJson: string +} + +type ToolUseInput = NonNullable + +function parseToolInput(inputJson: string): Record { + if (!inputJson.trim()) return {} + try { + const parsed = JSON.parse(inputJson) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Tool input must be a JSON object') + } + return parsed as Record + } catch (error) { + throw new Error(`Invalid Bedrock tool input: ${getErrorMessage(error)}`, { cause: error }) + } +} + +async function drainBedrockTurn( + stream: AsyncIterable, + controller: ReadableStreamDefaultController, + openTools: Map +): Promise<{ + text: string + toolUses: AssembledToolUse[] + inputTokens: number + outputTokens: number + stopReason?: string +}> { + let text = '' + const toolsByIndex = new Map() + let currentIndex: number | undefined + let inputTokens = 0 + let outputTokens = 0 + let stopReason: string | undefined + + for await (const event of stream) { + const streamError = getBedrockStreamError(event) + if (streamError) throw streamError + + if (event.contentBlockStart) { + currentIndex = event.contentBlockStart.contentBlockIndex + const start = event.contentBlockStart.start + if (start && 'toolUse' in start && start.toolUse) { + const id = start.toolUse.toolUseId || generateToolUseId(start.toolUse.name || 'tool') + const name = start.toolUse.name || '' + if (typeof currentIndex === 'number') { + toolsByIndex.set(currentIndex, { toolUseId: id, name, inputJson: '' }) + } + if (id && name && !openTools.has(id)) { + openTools.set(id, name) + controller.enqueue({ type: 'tool_call_start', id, name }) + } + } + continue + } + + if (event.contentBlockDelta) { + const idx = event.contentBlockDelta.contentBlockIndex ?? currentIndex + const delta = event.contentBlockDelta.delta + if (delta?.text) { + text += delta.text + // Live pending text: sinks render it now; the pump projects it to the + // answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'pending' }) + } + if (delta && 'toolUse' in delta && delta.toolUse?.input && typeof idx === 'number') { + const pending = toolsByIndex.get(idx) + if (pending) { + pending.inputJson += delta.toolUse.input + } + } + continue + } + + if (event.metadata?.usage) { + inputTokens = event.metadata.usage.inputTokens ?? inputTokens + outputTokens = event.metadata.usage.outputTokens ?? outputTokens + continue + } + + if (event.messageStop?.stopReason) { + stopReason = event.messageStop.stopReason + } + } + + return { + text, + toolUses: [...toolsByIndex.values()], + inputTokens, + outputTokens, + stopReason, + } +} + +/** + * Multi-turn Bedrock ConverseStream tool loop as agent-events-v1. + */ +export function createBedrockStreamingToolLoopStream( + options: CreateBedrockStreamingToolLoopStreamOptions +): ReadableStream { + const { + client, + modelId, + request, + messages: initialMessages, + system, + inferenceConfig, + bedrockTools, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + const originalToolChoice = options.toolChoice + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...initialMessages] + let toolChoice = originalToolChoice + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ReturnType['pricing'] | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } + + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + /** + * Bedrock's ToolChoice has no `none`, and `toolConfig` is required + * once the history carries toolUse/toolResult blocks — dropping it to + * force a text-only turn makes Bedrock reject the request outright. + * Keep the tools and relax to `auto`; the `finalSynthesis` guard below + * rejects a tool call the model makes anyway. + */ + const toolConfig: ToolConfiguration | undefined = bedrockTools.length + ? { tools: bedrockTools, toolChoice: finalSynthesis ? { auto: {} } : toolChoice } + : undefined + + const modelStart = Date.now() + const command = new ConverseStreamCommand({ + modelId, + messages: currentMessages, + system: system && system.length > 0 ? system : undefined, + inferenceConfig, + toolConfig, + }) + + const streamResponse = await client.send(command, { + abortSignal: loopAbortController.signal, + }) + if (!streamResponse.stream) { + throw new Error('No stream returned from Bedrock') + } + + const drained = await drainBedrockTurn(streamResponse.stream, controller, openToolStarts) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + tokens.input += drained.inputTokens + tokens.output += drained.outputTokens + tokens.total += drained.inputTokens + drained.outputTokens + + const turnCost = calculateCost(request.model, drained.inputTokens, drained.outputTokens) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + /** + * Only execute tools when the model actually stopped to call them. + * On `max_tokens` / `malformed_tool_use` the accumulated input JSON + * is truncated and would parse to empty or partial arguments. + */ + const toolsExecutable = drained.stopReason === 'tool_use' + if (drained.toolUses.length > 0 && !toolsExecutable) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Bedrock returned tool use with stop reason ${drained.stopReason ?? 'missing'}` + ) + } + if (finalSynthesis && drained.toolUses.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Bedrock returned tool use during final synthesis') + } + const executableToolUses = toolsExecutable ? drained.toolUses : [] + const cappedTextTurn = drained.stopReason === 'max_tokens' && openToolStarts.size === 0 + if ( + executableToolUses.length === 0 && + drained.stopReason !== 'end_turn' && + drained.stopReason !== 'stop_sequence' && + !cappedTextTurn + ) { + throw new Error( + `Bedrock stream ended with stop reason ${drained.stopReason ?? 'missing'}` + ) + } + + const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' + controller.enqueue({ type: 'turn_end', turn: turnTag }) + content = drained.text + + const assembledToolUses = executableToolUses.map((t) => ({ + toolUseId: t.toolUseId, + name: t.name, + input: parseToolInput(t.inputJson), + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + toolCalls: + assembledToolUses.length > 0 + ? assembledToolUses.map((t) => ({ + id: t.toolUseId || '', + name: t.name || '', + arguments: t.input, + })) + : undefined, + finishReason: drained.stopReason, + tokens: { + input: drained.inputTokens, + output: drained.outputTokens, + total: drained.inputTokens + drained.outputTokens, + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'bedrock', + }) + + const forcedCheck = checkForForcedToolUsage( + assembledToolUses.map((t) => ({ name: t.name || '' })), + toolChoice, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + hasUsedForcedTool = forcedCheck.hasUsedForcedTool + usedForcedTools = forcedCheck.usedForcedTools + } + + if (assembledToolUses.length === 0) { + sawFinalTurn = true + break + } + + const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + assembledToolUses.map(async (toolUse) => { + const toolCallStartTime = Date.now() + const toolName = toolUse.name || '' + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined + const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) + + try { + if (loopAbortController.signal.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: loopAbortController.signal, + }) + const toolCallEndTime = Date.now() + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status, + }) + return { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + } + } catch (error) { + const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'cancelled', + }) + throw error + } + if (isAbortError(error)) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) + const status: ToolCallEndStatus = 'error' + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status, + }) + return { + toolUse, + toolUseId, + toolName, + toolArgs: toolArgs ?? {}, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + } + } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + const assistantContent: ContentBlock[] = [ + // Bedrock rejects a blank text block, and a model can emit only + // whitespace before a tool call. + ...(drained.text.trim() ? [{ text: drained.text }] : []), + ...assembledToolUses.map((toolUse) => ({ + toolUse: { + toolUseId: toolUse.toolUseId, + name: toolUse.name, + input: toolUse.input, + }, + })), + ] + currentMessages.push({ + role: 'assistant' as ConversationRole, + content: assistantContent, + }) + + const toolResultContent: ContentBlock[] = [] + for (const value of orderedResults) { + const { toolUseId, toolName, toolParams, result, startTime, endTime, duration } = value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime, + endTime, + duration, + toolCallId: toolUseId, + }) + + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration, + result: resultContent, + success: result.success, + }) + + const toolResultBlock: ToolResultBlock = { + toolUseId, + content: [{ text: JSON.stringify(resultContent) }], + ...(supportsToolResultStatus(modelId) + ? { status: result.success ? 'success' : 'error' } + : {}), + } + toolResultContent.push({ toolResult: toolResultBlock }) + } + + if (toolResultContent.length > 0) { + currentMessages.push({ + role: 'user' as ConversationRole, + content: toolResultContent, + }) + } + + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + toolChoice = + remainingTools.length > 0 ? { tool: { name: remainingTools[0] } } : { auto: {} } + } else if (hasUsedForcedTool && typeof originalToolChoice === 'object') { + toolChoice = { auto: {} } + } + + iterationCount += 1 + } + + if (!sawFinalTurn) { + throw new Error('Bedrock tool loop ended without a final response') + } + + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Bedrock streaming tool loop failed', { + error: toError(cause).message, + }), + }) + } finally { + request.abortSignal?.removeEventListener('abort', abortFromRequest) + } + }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, + }) +} diff --git a/apps/sim/providers/bedrock/utils.stream.test.ts b/apps/sim/providers/bedrock/utils.stream.test.ts new file mode 100644 index 00000000000..b6b3c6b3d41 --- /dev/null +++ b/apps/sim/providers/bedrock/utils.stream.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromBedrockStream } from '@/providers/bedrock/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromBedrockStream', () => { + it('emits text only — no tool events (never executed on this path) and no invented thinking', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromBedrockStream( + (async function* () { + yield { + contentBlockStart: { + start: { + toolUse: { toolUseId: 'tooluse_1', name: 'http_request' }, + }, + }, + } as any + yield { + contentBlockDelta: { delta: { text: 'Done' } }, + } as any + yield { + metadata: { usage: { inputTokens: 2, outputTokens: 3 } }, + } as any + })(), + onComplete + ) + + const events = await collectEvents(stream) + expect(events).toEqual([{ type: 'text_delta', text: 'Done', turn: 'final' }]) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect(events.some((e) => e.type === 'tool_call_start')).toBe(false) + expect(onComplete).toHaveBeenCalledWith('Done', { inputTokens: 2, outputTokens: 3 }) + }) + + it('surfaces Bedrock event-stream exceptions', async () => { + const stream = createReadableStreamFromBedrockStream( + (async function* () { + yield { + modelStreamErrorException: { message: 'Model stream failed' }, + } as any + })() + ) + + await expect(collectEvents(stream)).rejects.toThrow('Model stream failed') + }) +}) diff --git a/apps/sim/providers/bedrock/utils.test.ts b/apps/sim/providers/bedrock/utils.test.ts index a667d614122..421e46bcff4 100644 --- a/apps/sim/providers/bedrock/utils.test.ts +++ b/apps/sim/providers/bedrock/utils.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getBedrockInferenceProfileId } from '@/providers/bedrock/utils' +import { getBedrockInferenceProfileId, supportsToolResultStatus } from '@/providers/bedrock/utils' describe('getBedrockInferenceProfileId', () => { it.concurrent('prefixes geo inference profile for models that require it', () => { @@ -44,3 +44,22 @@ describe('getBedrockInferenceProfileId', () => { ).toBe('amazon.titan-text-premier-v1:0') }) }) + +describe('supportsToolResultStatus', () => { + it.concurrent('accepts Claude and Nova through every ID form', () => { + expect(supportsToolResultStatus('bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0')).toBe(true) + expect(supportsToolResultStatus('us.anthropic.claude-opus-4-5-20251101-v1:0')).toBe(true) + expect(supportsToolResultStatus('anthropic.claude-haiku-4-5-20251001-v1:0')).toBe(true) + expect(supportsToolResultStatus('global.amazon.nova-2-lite-v1:0')).toBe(true) + expect(supportsToolResultStatus('bedrock/amazon.nova-micro-v1:0')).toBe(true) + }) + + it.concurrent('rejects every family that returns a ValidationException for it', () => { + expect(supportsToolResultStatus('us.meta.llama4-scout-17b-instruct-v1:0')).toBe(false) + expect(supportsToolResultStatus('bedrock/meta.llama3-3-70b-instruct-v1:0')).toBe(false) + expect(supportsToolResultStatus('mistral.mistral-large-2407-v1:0')).toBe(false) + expect(supportsToolResultStatus('cohere.command-r-plus-v1:0')).toBe(false) + // Titan shares the amazon vendor prefix but is not Nova. + expect(supportsToolResultStatus('bedrock/amazon.titan-text-premier-v1:0')).toBe(false) + }) +}) diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts index 82919624d09..a8e837b1142 100644 --- a/apps/sim/providers/bedrock/utils.ts +++ b/apps/sim/providers/bedrock/utils.ts @@ -1,6 +1,8 @@ import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { randomFloat } from '@sim/utils/random' +import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('BedrockUtils') @@ -10,37 +12,75 @@ export interface BedrockStreamUsage { outputTokens: number } +/** + * Converts an AWS event-stream exception member into an Error. + */ +export function getBedrockStreamError(event: ConverseStreamOutput): Error | undefined { + const exception = + event.internalServerException ?? + event.modelStreamErrorException ?? + event.validationException ?? + event.throttlingException ?? + event.serviceUnavailableException + if (!exception) return undefined + return new Error(exception.message || getErrorMessage(exception, 'Bedrock stream error'), { + cause: exception, + }) +} + +/** + * Bedrock ConverseStream → agent-events-v1 for the legacy (non-tool-loop) + * streaming path. Text deltas only: tools on this path are never executed, so + * emitting `tool_call_start` here would leave a chip running forever with no + * matching end. Sim does not request Bedrock reasoning, so there is no + * thinking to forward either. + */ export function createReadableStreamFromBedrockStream( bedrockStream: AsyncIterable, onComplete?: (content: string, usage: BedrockStreamUsage) => void -): ReadableStream { +): ReadableStream { let fullContent = '' let inputTokens = 0 let outputTokens = 0 + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { try { - for await (const event of bedrockStream) { + streamIterator = bedrockStream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const event = next.value + const streamError = getBedrockStreamError(event) + if (streamError) throw streamError if (event.contentBlockDelta?.delta?.text) { const text = event.contentBlockDelta.delta.text fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + controller.enqueue({ type: 'text_delta', text, turn: 'final' }) } else if (event.metadata?.usage) { inputTokens = event.metadata.usage.inputTokens ?? 0 outputTokens = event.metadata.usage.outputTokens ?? 0 } } + if (cancelled) return if (onComplete) { onComplete(fullContent, { inputTokens, outputTokens }) } controller.close() } catch (err) { - controller.error(err) + if (!cancelled) { + controller.error(err) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } @@ -100,6 +140,32 @@ const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ 'cohere.command-r-plus-v1:0', ]) +/** Cross-region inference profile prefixes Bedrock prepends to a base model ID. */ +const GEO_PROFILE_PREFIX_PATTERN = /^(us-gov|us|eu|apac|au|ca|jp|global)\./ + +/** + * Strips Sim's `bedrock/` namespace and any cross-region inference prefix, + * leaving the bare `.` ID that capability checks key off. + */ +function getBedrockBaseModelId(modelId: string): string { + const withoutNamespace = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId + return withoutNamespace.replace(GEO_PROFILE_PREFIX_PATTERN, '') +} + +/** + * Whether the model accepts `status` on a `toolResult` content block. + * + * Only Amazon Nova and Anthropic Claude 3/4 support it; Llama, Mistral, Cohere, + * and Titan reject the whole request with + * `ValidationException: This model doesn't support the status field.` + * + * Source: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html + */ +export function supportsToolResultStatus(modelId: string): boolean { + const baseModelId = getBedrockBaseModelId(modelId) + return baseModelId.startsWith('anthropic.') || baseModelId.startsWith('amazon.nova') +} + /** * Converts a model ID to the Bedrock inference profile format. * AWS Bedrock requires inference profile IDs (e.g., us.anthropic.claude-...) @@ -113,7 +179,7 @@ const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ export function getBedrockInferenceProfileId(modelId: string, region: string): string { const baseModelId = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId - if (/^(us-gov|us|eu|apac|au|ca|jp|global)\./.test(baseModelId)) { + if (GEO_PROFILE_PREFIX_PATTERN.test(baseModelId)) { return baseModelId } diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index c351ffc4268..aaf192a8fe8 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -1,13 +1,17 @@ import { Cerebras } from '@cerebras/cerebras_cloud_sdk' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import type { CerebrasResponse } from '@/providers/cerebras/types' import { createReadableStreamFromCerebrasStream } from '@/providers/cerebras/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -133,6 +137,7 @@ export const cerebrasProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { output.content = content @@ -230,9 +235,24 @@ export const cerebrasProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -250,6 +270,9 @@ export const cerebrasProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Cerebras):', { error: toError(error).message, @@ -272,25 +295,21 @@ export const cerebrasProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: filteredToolCalls.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: filteredToolCalls, + reasoningFields: ['reasoning'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', name: toolName, @@ -299,10 +318,12 @@ export const cerebrasProvider: ProviderConfig = { duration: duration, toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -357,7 +378,7 @@ export const cerebrasProvider: ProviderConfig = { } finalPayload.tool_choice = 'none' - const finalResponse = (await client.chat.completions.create( + currentResponse = (await client.chat.completions.create( finalPayload, request.abortSignal ? { signal: request.abortSignal } : undefined )) as CerebrasResponse @@ -375,22 +396,23 @@ export const cerebrasProvider: ProviderConfig = { modelTime += thisModelTime - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 } enrichLastModelSegmentFromChatCompletions( timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'cerebras' } ) + iterationCount++ break } @@ -428,16 +450,56 @@ export const cerebrasProvider: ProviderConfig = { } } - if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + if (iterationCount === MAX_TOOL_ITERATIONS && cappedToolCalls?.length) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + cappedToolCalls, + { model: request.model, provider: 'cerebras' } + ) + + const finalModelStartTime = Date.now() + currentResponse = (await client.chat.completions.create( + { + ...payload, + messages: currentMessages, + tool_choice: 'none', + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + )) as CerebrasResponse + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'cerebras' } ) + iterationCount++ } } catch (error) { logger.error('Error in Cerebras tool processing:', { error }) + throw error } const providerEndTime = Date.now() @@ -445,21 +507,8 @@ export const cerebrasProvider: ProviderConfig = { const totalDuration = providerEndTime - providerStartTime if (request.stream) { - logger.info('Using streaming for final Cerebras response after tool processing') - - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'auto', - stream: true, - } - - const streamResponse: any = await client.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, @@ -481,8 +530,8 @@ export const cerebrasProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -492,28 +541,12 @@ export const cerebrasProvider: ProviderConfig = { } : undefined, isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -546,6 +579,10 @@ export const cerebrasProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/cerebras/utils.ts b/apps/sim/providers/cerebras/utils.ts index 830d0d67140..96a36bd7be2 100644 --- a/apps/sim/providers/cerebras/utils.ts +++ b/apps/sim/providers/cerebras/utils.ts @@ -1,5 +1,6 @@ import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' interface CerebrasChunk { choices?: Array<{ @@ -15,12 +16,17 @@ interface CerebrasChunk { } /** - * Creates a ReadableStream from a Cerebras streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Cerebras streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromCerebrasStream( cerebrasStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(cerebrasStream as any, 'Cerebras', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(cerebrasStream as any, { + providerName: 'Cerebras', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/cost-policy.test.ts b/apps/sim/providers/cost-policy.test.ts new file mode 100644 index 00000000000..5d7ebd9869e --- /dev/null +++ b/apps/sim/providers/cost-policy.test.ts @@ -0,0 +1,293 @@ +/** + * @vitest-environment node + */ +import { envFlagsMockFns, resetEnvFlagsMock } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import type { NormalizedBlockOutput } from '@/executor/types' +import { + applyModelCostPolicy, + applySegmentCostPolicy, + calculateBillableModelCost, + installStreamingCostPolicy, + LIST_PRICE_POLICY, + priceModelUsage, + resolveModelCostPolicy, + resolveProxiedModelCost, + withoutToolCost, +} from '@/providers/cost-policy' +import { calculateCost } from '@/providers/utils' + +/** A model Sim hosts, so its usage is billable when no BYOK key is present. */ +const HOSTED_MODEL = 'claude-opus-4-6' +/** A model reached only with a caller-supplied key, so Sim never bills it. */ +const SELF_KEYED_MODEL = 'llama-3.3-70b-versatile' + +describe('resolveModelCostPolicy', () => { + afterEach(resetEnvFlagsMock) + + it('bills hosted models at the configured multiplier', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + expect(resolveModelCostPolicy(HOSTED_MODEL)).toEqual({ billable: true, multiplier: 2 }) + }) + + it('does not bill when the workspace supplied its own key', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + expect(resolveModelCostPolicy(HOSTED_MODEL, true)).toEqual({ billable: false, multiplier: 0 }) + }) + + it('does not bill models Sim does not host', () => { + expect(resolveModelCostPolicy(SELF_KEYED_MODEL)).toEqual({ billable: false, multiplier: 0 }) + }) +}) + +describe('applyModelCostPolicy', () => { + it('scales model cost by the multiplier and leaves tool cost untouched', () => { + const projected = applyModelCostPolicy( + { input: 1, output: 2, total: 3.5, toolCost: 0.5 }, + { billable: true, multiplier: 2 } + ) + + expect(projected).toMatchObject({ input: 2, output: 4, total: 6.5, toolCost: 0.5 }) + }) + + it('returns the cost unchanged when the multiplier is 1', () => { + const cost = { input: 1, output: 2, total: 3 } + expect(applyModelCostPolicy(cost, { billable: true, multiplier: 1 })).toBe(cost) + }) + + it('zeroes model cost but preserves tool cost when not billable', () => { + const projected = applyModelCostPolicy( + { input: 1, output: 2, total: 3.25, toolCost: 0.25 }, + { billable: false, multiplier: 0 } + ) + + expect(projected).toMatchObject({ input: 0, output: 0, total: 0.25, toolCost: 0.25 }) + }) +}) + +describe('priceModelUsage', () => { + /** Claude Sonnet 5: $2/MTok input, $0.20/MTok cached, $10/MTok output. */ + const PRICED_MODEL = 'claude-sonnet-5' + const PER_TOKEN_INPUT = 2 / 1_000_000 + + it('prices cache reads at the cached rate, not the base rate', () => { + const cached = priceModelUsage( + PRICED_MODEL, + { input: 0, output: 0, cacheRead: 100_000 }, + LIST_PRICE_POLICY + ) + const uncached = priceModelUsage(PRICED_MODEL, { input: 100_000, output: 0 }, LIST_PRICE_POLICY) + + expect(cached.input).toBeCloseTo(uncached.input / 10, 8) + }) + + it('prices each write bucket at its own premium over the base input rate', () => { + const cost = priceModelUsage( + PRICED_MODEL, + { + input: 0, + output: 0, + cacheWrites: [ + { tokens: 100_000, inputRateMultiplier: 1.25 }, + { tokens: 100_000, inputRateMultiplier: 2 }, + ], + }, + LIST_PRICE_POLICY + ) + + expect(cost.input).toBeCloseTo(100_000 * PER_TOKEN_INPUT * 3.25, 8) + }) + + it('matches the plain uncached calculation when there is no cache activity', () => { + const priced = priceModelUsage(PRICED_MODEL, { input: 1000, output: 500 }, LIST_PRICE_POLICY) + const direct = calculateCost(PRICED_MODEL, 1000, 500) + + expect(priced).toMatchObject({ + input: direct.input, + output: direct.output, + total: direct.total, + }) + }) + + it('applies the policy multiplier to every bucket', () => { + const usage = { + input: 1000, + output: 500, + cacheRead: 2000, + cacheWrites: [{ tokens: 1000, inputRateMultiplier: 1.25 }], + } + + const single = priceModelUsage(PRICED_MODEL, usage, { billable: true, multiplier: 1 }) + const tripled = priceModelUsage(PRICED_MODEL, usage, { billable: true, multiplier: 3 }) + + expect(tripled.total).toBeCloseTo(single.total * 3, 8) + }) + + it('charges nothing when the policy is not billable', () => { + const cost = priceModelUsage( + PRICED_MODEL, + { input: 1000, output: 500, cacheRead: 5000 }, + { billable: false, multiplier: 0 } + ) + + expect(cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) + + /** + * The regression this guards: Anthropic reports `input_tokens` already + * excluding cache tokens while OpenAI and Gemini report a cached subset of + * the prompt total. A normalization mistake in either adapter shows up as + * two different prices for one real token split. + */ + it('prices the same real token split identically across opposite wire shapes', () => { + const anthropicShaped = priceModelUsage( + PRICED_MODEL, + { input: 1000, output: 500, cacheRead: 4000 }, + LIST_PRICE_POLICY + ) + + const openAIPromptTotal = 5000 + const openAICached = 4000 + const openAIShaped = priceModelUsage( + PRICED_MODEL, + { + input: openAIPromptTotal - openAICached, + output: 500, + cacheRead: openAICached, + }, + LIST_PRICE_POLICY + ) + + expect(openAIShaped).toEqual(anthropicShaped) + }) +}) + +describe('withoutToolCost', () => { + it('removes a provider-folded tool cost and rebases the total', () => { + expect(withoutToolCost({ input: 1, output: 2, total: 3.5, toolCost: 0.5 })).toEqual({ + input: 1, + output: 2, + total: 3, + }) + }) + + it('leaves a model-only cost untouched', () => { + const cost = { input: 1, output: 2, total: 3 } + expect(withoutToolCost(cost)).toBe(cost) + }) +}) + +describe('calculateBillableModelCost', () => { + afterEach(resetEnvFlagsMock) + + it('matches the streaming projection for the same tokens', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(3) + + const nonStreaming = calculateBillableModelCost(HOSTED_MODEL, 1000, 500) + const unscaled = calculateBillableModelCost(HOSTED_MODEL, 1000, 500) + // The streaming path projects a provider cost computed without the + // multiplier, so remove it before comparing against the direct calculation. + const providerCost = { + input: unscaled.input / 3, + output: unscaled.output / 3, + total: unscaled.total / 3, + } + const streaming = applyModelCostPolicy(providerCost, resolveModelCostPolicy(HOSTED_MODEL)) + + expect(streaming.total).toBeCloseTo(nonStreaming.total, 8) + }) + + it('returns a zero cost carrying pricing for models Sim does not host', () => { + const cost = calculateBillableModelCost(SELF_KEYED_MODEL, 1000, 500) + expect(cost).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(cost.pricing).toBeDefined() + }) +}) + +describe('installStreamingCostPolicy', () => { + afterEach(resetEnvFlagsMock) + + it('applies the multiplier to cost written after the stream starts', () => { + const output = { cost: { input: 0, output: 0, total: 0 } } as NormalizedBlockOutput + installStreamingCostPolicy(output, { billable: true, multiplier: 2 }) + + output.cost = { input: 1, output: 2, total: 3 } + + expect(output.cost).toMatchObject({ input: 2, output: 4, total: 6 }) + }) + + it('keeps tool cost from a settled stream that wrote its cost before the policy was installed', () => { + const output = { + cost: { input: 1, output: 2, total: 3.75, toolCost: 0.75 }, + } as NormalizedBlockOutput + + installStreamingCostPolicy(output, { billable: false, multiplier: 0 }) + + expect(output.cost).toMatchObject({ input: 0, output: 0, total: 0.75, toolCost: 0.75 }) + }) + + it('zeroes model cost written by a provider for a model Sim does not host', () => { + const output = { cost: { input: 0, output: 0, total: 0 } } as NormalizedBlockOutput + installStreamingCostPolicy(output, resolveModelCostPolicy(SELF_KEYED_MODEL)) + + output.cost = { input: 0.5, output: 1.5, total: 2 } + + expect(output.cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) +}) + +describe('applySegmentCostPolicy', () => { + it('zeroes model segments only when the call is not billable', () => { + const segments = [ + { type: 'model', cost: { input: 1, output: 2, total: 3 } }, + { type: 'tool', cost: { total: 0.01 } }, + ] + + applySegmentCostPolicy(segments, { billable: true, multiplier: 1 }) + expect(segments[0].cost).toMatchObject({ total: 3 }) + + applySegmentCostPolicy(segments, { billable: false, multiplier: 0 }) + expect(segments[0].cost).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(segments[1].cost).toMatchObject({ total: 0.01 }) + }) +}) + +describe('resolveProxiedModelCost', () => { + it('passes through the proxy cost without recomputing it', () => { + const pricing = { input: 5, output: 25, updatedAt: '2026-04-01' } + expect( + resolveProxiedModelCost({ input: 1, output: 2, total: 3, toolCost: 0.5, pricing }) + ).toEqual({ + input: 1, + output: 2, + total: 3, + toolCost: 0.5, + pricing, + }) + }) + + it('treats a missing or malformed cost as no billable usage', () => { + expect(resolveProxiedModelCost(undefined)).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(resolveProxiedModelCost(0.003)).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(resolveProxiedModelCost({ input: Number.NaN, output: 1, total: 1 })).toMatchObject({ + input: 0, + output: 1, + total: 1, + }) + expect( + resolveProxiedModelCost({ input: 1, output: 1, total: Number.POSITIVE_INFINITY }) + ).toMatchObject({ total: 0 }) + expect(resolveProxiedModelCost({ input: 1, output: 1, total: '2' })).toMatchObject({ total: 0 }) + }) + + it('never lets a corrupt negative figure credit the run', () => { + expect(resolveProxiedModelCost({ input: -5, output: -1, total: -6 })).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) + expect(resolveProxiedModelCost({ input: 1, output: 1, total: 2, toolCost: -3 })).toMatchObject({ + toolCost: 0, + }) + }) +}) diff --git a/apps/sim/providers/cost-policy.ts b/apps/sim/providers/cost-policy.ts new file mode 100644 index 00000000000..d89b978debd --- /dev/null +++ b/apps/sim/providers/cost-policy.ts @@ -0,0 +1,303 @@ +import { getCostMultiplier } from '@/lib/core/config/env-flags' +import type { NormalizedBlockOutput } from '@/executor/types' +import type { ModelPricing } from '@/providers/types' +import { calculateCost, shouldBillModelUsage } from '@/providers/utils' + +/** + * Single source of truth for whether a model response is charged to the caller + * and at what margin. + * + * Two concerns are deliberately separated: + * - Providers own token → USD *pricing*; they alone know cache tiers, reasoning + * tokens, and per-turn accumulation. + * - This module owns billing *policy*: whether Sim supplied the credentials at + * all, and the production margin multiplier. + * + * Every surface that turns tokens into a charge — the streaming and + * non-streaming provider paths, Router, Evaluator, and Pi — resolves policy + * here, so a model costs the same no matter which path produced it. + */ + +/** Cost shape written onto block output. Mirrors `BlockCost`. */ +export interface ModelCost { + input: number + output: number + total: number + toolCost?: number + pricing?: ModelPricing +} + +/** Cost that always carries pricing, as `ProviderResponse['cost']` requires. */ +export type PricedModelCost = ModelCost & { pricing: ModelPricing } + +export interface ModelCostPolicy { + /** True when Sim supplied the credentials and must charge for the tokens. */ + billable: boolean + /** Margin applied to billable model cost; `0` when the call is not billable. */ + multiplier: number +} + +/** + * Prices at the vendor's list rate with no margin. + * + * Provider adapters use this: they price tokens the vendor charged, and the + * central layer applies the billability gate and margin afterwards (via + * {@link applyModelCostPolicy} or {@link installStreamingCostPolicy}). A + * provider applying the policy itself would double-count the multiplier. + */ +export const LIST_PRICE_POLICY: ModelCostPolicy = Object.freeze({ + billable: true, + multiplier: 1, +}) + +/** Pricing echoed on a zero cost so consumers can tell "not billed" from "unpriced". */ +const NOT_BILLED_PRICING: ModelPricing = Object.freeze({ + input: 0, + output: 0, + updatedAt: new Date(0).toISOString(), +}) + +function roundCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Resolves the billing policy for a model response. + * + * A response is billable only when the model is one Sim hosts (so Sim's own key + * paid the provider) and the workspace did not supply its own key. + */ +export function resolveModelCostPolicy(model: string, isBYOK = false): ModelCostPolicy { + const billable = !isBYOK && shouldBillModelUsage(model) + return { billable, multiplier: billable ? getCostMultiplier() : 0 } +} + +/** Zero model cost, preserving any tool cost already charged at the tool's own rate. */ +export function notBilledCost(toolCost = 0): PricedModelCost { + return { + input: 0, + output: 0, + total: roundCost(toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: NOT_BILLED_PRICING, + } +} + +/** + * Projects a model cost through the billing policy. + * + * Tool cost passes through untouched — it is already billed at the tool's own + * rate by the tool layer and must not pick up the model margin. + */ +export function applyModelCostPolicy( + cost: ModelCost | undefined, + policy: ModelCostPolicy +): ModelCost { + const toolCost = cost?.toolCost ?? 0 + + if (!cost || !policy.billable) { + return notBilledCost(toolCost) + } + + if (policy.multiplier === 1) { + return cost + } + + const modelTotal = cost.total - toolCost + + return { + ...cost, + input: roundCost(cost.input * policy.multiplier), + output: roundCost(cost.output * policy.multiplier), + total: roundCost(modelTotal * policy.multiplier + toolCost), + } +} + +/** A cache write bucket and its premium over the model's base input rate. */ +export interface CacheWriteUsage { + tokens: number + /** Anthropic: 1.25 (5m) or 2 (1h). OpenAI: 1.25 on GPT-5.6+, free before it. */ + inputRateMultiplier: number +} + +/** + * Normalized token usage for one model call. + * + * Providers report cache usage in incompatible shapes — Anthropic's + * `input_tokens` already excludes cache tokens, while OpenAI's `cached_tokens` + * and Gemini's `cachedContentTokenCount` are subsets of their prompt totals. + * Each provider adapter resolves that difference before building this; nothing + * downstream branches on provider again. + */ +export interface ModelUsage { + /** Tokens billed at the base input rate. Always EXCLUDES cache reads/writes. */ + input: number + output: number + /** Tokens served from the provider's cache, billed at the cachedInput rate. */ + cacheRead?: number + /** Tokens written to the cache, each bucket at its own premium. */ + cacheWrites?: CacheWriteUsage[] +} + +/** + * The single cache-aware pricing function. + * + * Every surface that turns tokens into a charge routes through here, so a cache + * hit is priced identically whether it came from Anthropic, OpenAI, Gemini, or + * an OpenAI-compatible vendor. Callers must not pre-apply the policy multiplier + * or the cached rate themselves. + * + * Token counts are validated by the provider adapter that builds the + * {@link ModelUsage}, which is the only layer that knows the vendor's shape and + * can enforce that cache buckets are a subset of the prompt total. This function + * re-validates nothing. + */ +export function priceModelUsage( + model: string, + usage: ModelUsage, + policy: ModelCostPolicy +): PricedModelCost { + if (!policy.billable) { + return notBilledCost() + } + + const multiplier = policy.multiplier + const base = calculateCost(model, usage.input, usage.output, false, multiplier, multiplier) + + const cacheRead = usage.cacheRead ?? 0 + const read = cacheRead > 0 ? calculateCost(model, cacheRead, 0, true, multiplier, 0) : undefined + + let writeInputCost = 0 + for (const write of usage.cacheWrites ?? []) { + if (write.tokens <= 0) continue + writeInputCost += calculateCost( + model, + write.tokens, + 0, + false, + multiplier * write.inputRateMultiplier, + 0 + ).input + } + + const input = roundCost(base.input + (read?.input ?? 0) + writeInputCost) + const output = base.output + + return { + input, + output, + total: roundCost(input + output), + pricing: base.pricing, + } +} + +/** + * Prices tokens for a model and applies the billing policy in one step. Use + * wherever a caller holds raw token counts and needs the charge Sim records. + * + * Cache-free by design: a caller that has cache usage also knows its tiers, so + * it builds a {@link ModelUsage} and calls {@link priceModelUsage} directly. + */ +export function calculateBillableModelCost( + model: string, + promptTokens = 0, + completionTokens = 0, + options: { isBYOK?: boolean } = {} +): PricedModelCost { + return priceModelUsage( + model, + { input: promptTokens, output: completionTokens }, + resolveModelCostPolicy(model, options.isBYOK) + ) +} + +/** + * Drops a tool cost a provider folded into its own total. + * + * `executeProviderRequest` re-derives tool cost from `response.toolResults` and + * adds it after the policy is applied, so a provider that already accounted for + * it would otherwise have it counted twice. + */ +export function withoutToolCost(cost: ModelCost): ModelCost { + if (cost.toolCost === undefined) return cost + + const { toolCost: _toolCost, ...model } = cost + return { ...model, total: roundCost(model.input + model.output) } +} + +/** Rejects NaN, Infinity, and negatives — a negative would credit the run. */ +function billableAmount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +/** + * Reads the cost off a JSON response returned by the `/api/providers` proxy. + * + * The proxy already resolved key provenance and applied the policy, so its cost + * is authoritative and must never be recomputed from token counts — the caller + * cannot see whether a workspace BYOK key paid for the call. A response with no + * usable cost carried no billable usage. + */ +export function resolveProxiedModelCost(cost: unknown): ModelCost { + if (!cost || typeof cost !== 'object') { + return notBilledCost() + } + + const { input, output, total, toolCost, pricing } = cost as Partial + return { + input: billableAmount(input), + output: billableAmount(output), + total: billableAmount(total), + ...(toolCost === undefined ? {} : { toolCost: billableAmount(toolCost) }), + // Kept so downstream cost estimators can tell a priced zero from an + // unpriced block and leave the charge alone. + ...(pricing ? { pricing } : {}), + } +} + +/** + * Applies the billing policy to a streaming block output. + * + * Streaming providers write their final cost from inside the stream drain, long + * after `executeRequest` returns, so the policy is installed as an accessor + * rather than applied to a value. A cost already present — providers that + * settle their tool loop before returning — becomes the initial value, so its + * tool cost survives. + */ +export function installStreamingCostPolicy( + output: NormalizedBlockOutput, + policy: ModelCostPolicy +): void { + let raw = output.cost as ModelCost | undefined + + Object.defineProperty(output, 'cost', { + get: () => applyModelCostPolicy(raw, policy), + set: (value: ModelCost | undefined) => { + raw = value + }, + configurable: true, + enumerable: true, + }) +} + +/** + * Zeroes per-segment model cost on already-populated time segments when the + * call is not billable. + * + * Segment costs come from the trace enrichers, which price tokens without + * knowing key provenance. Block-level cost stays authoritative for billing — + * `calculateCostSummary` skips model children of a span that has its own cost — + * so this only keeps the displayed breakdown from contradicting it. + */ +export function applySegmentCostPolicy( + segments: Array<{ type?: string; cost?: { input?: number; output?: number; total?: number } }>, + policy: ModelCostPolicy +): void { + if (policy.billable) return + + for (const segment of segments) { + if (segment.type === 'model' && segment.cost) { + segment.cost = { input: 0, output: 0, total: 0 } + } + } +} diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts new file mode 100644 index 00000000000..ff5cf829824 --- /dev/null +++ b/apps/sim/providers/deepseek/index.test.ts @@ -0,0 +1,148 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate, mockExecuteTool, mockPrepareToolsWithUsageControl } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockPrepareToolsWithUsageControl: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => ['deepseek-chat']), + getProviderDefaultModel: vi.fn(() => 'deepseek-chat'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/deepseek/utils', () => ({ + createReadableStreamFromDeepseekStream: vi.fn(), +})) + +vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ + createOpenAICompatStreamingToolLoopStream: vi.fn(), +})) + +vi.mock('@/providers/streaming-execution', () => ({ + createStreamingExecution: vi.fn((args) => args), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +import { deepseekProvider } from '@/providers/deepseek/index' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'deepseek-chat', + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hi' }], + ...overrides, + } +} + +describe('deepseekProvider thinking payload', () => { + beforeEach(() => { + mockCreate.mockReset() + mockExecuteTool.mockReset() + mockPrepareToolsWithUsageControl.mockReset() + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }) + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it('sets thinking: { type: enabled } when thinkingLevel is enabled', async () => { + await deepseekProvider.executeRequest(request({ thinkingLevel: 'enabled' })) + expect(mockCreate).toHaveBeenCalled() + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toEqual({ type: 'enabled' }) + }) + + it('sets thinking: { type: disabled } when thinkingLevel is none (API default is enabled)', async () => { + await deepseekProvider.executeRequest(request({ thinkingLevel: 'none' })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toEqual({ type: 'disabled' }) + }) + + it('omits thinking when thinkingLevel is unset', async () => { + await deepseekProvider.executeRequest(request()) + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toBeUndefined() + }) + + it('selects the live tool loop without a caller flag', async () => { + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [ + { + type: 'function', + function: { name: 'lookup', description: 'Lookup', parameters: {} }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + vi.mocked(createOpenAICompatStreamingToolLoopStream).mockReturnValue( + new ReadableStream() as never + ) + + const result = (await deepseekProvider.executeRequest( + request({ + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + )) as unknown as { + createStream: (handles: { + output: { content?: string } + finalizeTiming: () => void + }) => ReadableStream + } + + const output: { content?: string } = {} + result.createStream({ output, finalizeTiming: vi.fn() }) + + expect(createOpenAICompatStreamingToolLoopStream).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 37f09254be3..93bf998a270 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -1,12 +1,15 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { StreamingExecution } from '@/executor/types' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -20,7 +23,6 @@ import { calculateCost, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -48,7 +50,7 @@ export const deepseekProvider: ProviderConfig = { try { const deepseek = new OpenAI({ apiKey: request.apiKey, - baseURL: 'https://api.deepseek.com/v1', + baseURL: 'https://api.deepseek.com', }) const allMessages = [] @@ -84,40 +86,134 @@ export const deepseekProvider: ProviderConfig = { if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_tokens = request.maxTokens + /** + * DeepSeek Think mode: reasoning_content streams when enabled (or inherent + * on reasoner). The API default is enabled, so 'none' must explicitly send + * `disabled`; unset sends nothing to preserve the legacy request shape. + */ + const usesThinkingMode = + request.thinkingLevel !== undefined + ? request.thinkingLevel !== 'none' + : request.model !== 'deepseek-chat' + if (request.thinkingLevel && request.thinkingLevel !== 'none') { + payload.thinking = { type: 'enabled' } + } else if (request.thinkingLevel === 'none') { + payload.thinking = { type: 'disabled' } + } + if (request.reasoningEffort && !['auto', 'none'].includes(request.reasoningEffort)) { + payload.reasoning_effort = + request.reasoningEffort === 'xhigh' + ? 'max' + : request.reasoningEffort === 'low' || request.reasoningEffort === 'medium' + ? 'high' + : request.reasoningEffort + } + let preparedTools: ReturnType | null = null if (tools?.length) { preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'deepseek') const { tools: filteredTools, toolChoice } = preparedTools - if (filteredTools?.length && toolChoice) { + if (filteredTools?.length) { payload.tools = filteredTools - payload.tool_choice = toolChoice + if (toolChoice && !usesThinkingMode) { + payload.tool_choice = toolChoice + } logger.info('Deepseek request configuration:', { toolCount: filteredTools.length, toolChoice: - typeof toolChoice === 'string' - ? toolChoice - : toolChoice.type === 'function' - ? `force:${toolChoice.function.name}` - : toolChoice.type === 'tool' - ? `force:${toolChoice.name}` - : toolChoice.type === 'any' - ? `force:${toolChoice.any?.name || 'unknown'}` - : 'unknown', + !toolChoice || usesThinkingMode + ? 'provider-default' + : typeof toolChoice === 'string' + ? toolChoice + : toolChoice.type === 'function' + ? `force:${toolChoice.function.name}` + : toolChoice.type === 'tool' + ? `force:${toolChoice.name}` + : toolChoice.type === 'any' + ? `force:${toolChoice.any?.name || 'unknown'}` + : 'unknown', model: request.model, }) } } - if (request.stream && (!tools || tools.length === 0)) { + if (request.stream && payload.tools?.length) { + logger.info('Using streaming tool loop for DeepSeek request') + + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request, + basePayload: payload, + messages: + // double-cast-allowed: formatMessagesForProvider returns loosely-typed provider messages that are wire-compatible with the OpenAI chat.completions message params the shared loop expects + formattedMessages as unknown as OpenAI.Chat.Completions.ChatCompletionMessageParam[], + createStream: async (params, options) => + deepseek.chat.completions.create( + { + ...params, + stream: true, + stream_options: { include_usage: true }, + }, + options + ), + logger, + timeSegments, + forcedTools, + /** + * DeepSeek requires reasoning_content passed back on tool-call + * turns whenever the API returns it (thinking defaults to enabled + * server-side); it is ignored on non-tool turns, so preserving + * unconditionally is always safe. + */ + preserveAssistantReasoning: true, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + + if (request.stream && !payload.tools?.length) { logger.info('Using streaming response for DeepSeek request (no tools)') const streamResponse = await deepseek.chat.completions.create( { ...payload, stream: true, + stream_options: { include_usage: true }, }, request.abortSignal ? { signal: request.abortSignal } : undefined ) @@ -130,26 +226,39 @@ export const deepseekProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromDeepseekStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } + finalizeTiming() } - }), + ), }) return streamingResult @@ -239,10 +348,25 @@ export const deepseekProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -260,6 +384,9 @@ export const deepseekProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -279,11 +406,21 @@ export const deepseekProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ + const executionResults = await Promise.all(toolExecutionPromises) + + const assistantMessage = currentResponse.choices[0]?.message + const assistantHistory: { + role: string + content: string + tool_calls: Array<{ + id: string + type: string + function: { name: string; arguments: string } + }> + reasoning_content?: string + } = { role: 'assistant', - content: null, + content: assistantMessage?.content ?? '', tool_calls: toolCallsInResponse.map((tc) => ({ id: tc.id, type: 'function', @@ -292,13 +429,25 @@ export const deepseekProvider: ProviderConfig = { arguments: tc.function.arguments, }, })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + } + /** + * DeepSeek requires reasoning_content passed back on tool-call turns + * whenever the API returns it (thinking defaults to enabled + * server-side, so this applies even without an explicit thinking + * level); it is ignored on non-tool turns. + */ + if (assistantMessage) { + const reasoningContent = (assistantMessage as { reasoning_content?: string }) + .reasoning_content + if (typeof reasoningContent === 'string' && reasoningContent.length > 0) { + assistantHistory.reasoning_content = reasoningContent + } + } + currentMessages.push(assistantHistory) + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -309,10 +458,12 @@ export const deepseekProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -426,87 +577,13 @@ export const deepseekProvider: ProviderConfig = { } } catch (error) { logger.error('Error in Deepseek request:', { error }) + throw error } const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - if (request.stream) { - logger.info('Using streaming for final DeepSeek response after tool processing') - - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'auto', - stream: true, - } - - const streamResponse = await deepseek.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) - - return streamingResult - } - return { content, model: request.model, @@ -534,6 +611,9 @@ export const deepseekProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/deepseek/utils.ts b/apps/sim/providers/deepseek/utils.ts index 1c7a67488b2..ccfeb9e10fc 100644 --- a/apps/sim/providers/deepseek/utils.ts +++ b/apps/sim/providers/deepseek/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a DeepSeek streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a DeepSeek streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromDeepseekStream( deepseekStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(deepseekStream, 'Deepseek', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(deepseekStream, { + providerName: 'Deepseek', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 8c38a5b7303..f20d8568630 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/fireworks/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,16 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = ( + assistant: { content?: string | null; reasoning_content?: string } = {} +) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning_content !== undefined + ? { reasoning_content: assistant.reasoning_content } + : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -218,15 +226,54 @@ describe('fireworksProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Fireworks assistant content and reasoning_content on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await fireworksProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await fireworksProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await fireworksProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index c6981d4abf0..0ec540a8c2b 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { supportsNativeStructuredOutputs, } from '@/providers/fireworks/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -167,6 +171,7 @@ export const fireworksProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -262,10 +267,25 @@ export const fireworksProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -283,6 +303,9 @@ export const fireworksProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Fireworks):', { error: toError(error).message, @@ -305,26 +328,21 @@ export const fireworksProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -335,10 +353,12 @@ export const fireworksProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -416,84 +436,61 @@ export const fireworksProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'fireworks' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'fireworks', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'fireworks' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -549,6 +546,45 @@ export const fireworksProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -566,7 +602,7 @@ export const fireworksProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -589,6 +625,10 @@ export const fireworksProvider: ProviderConfig = { } logger.error('Error in Fireworks request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/fireworks/utils.ts b/apps/sim/providers/fireworks/utils.ts index 70444e07b69..c4abd3111bd 100644 --- a/apps/sim/providers/fireworks/utils.ts +++ b/apps/sim/providers/fireworks/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Checks if a model supports native structured outputs (json_schema). @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Fireworks streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Fireworks streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Fireworks', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Fireworks', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/gemini/core.cost.test.ts b/apps/sim/providers/gemini/core.cost.test.ts new file mode 100644 index 00000000000..96d0c660300 --- /dev/null +++ b/apps/sim/providers/gemini/core.cost.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeGeminiRequest } from '@/providers/gemini/core' +import type { ProviderResponse } from '@/providers/types' +import { calculateCost } from '@/providers/utils' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers', () => ({ + MAX_TOOL_ITERATIONS: 5, +})) + +/** input 0.30/M, cachedInput 0.03/M, output 2.50/M. */ +const MODEL = 'gemini-2.5-flash' + +function textTurn(usageMetadata: Record) { + return { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata, + } +} + +function toolTurn(usageMetadata: Record) { + return { + candidates: [ + { + content: { role: 'model', parts: [{ functionCall: { name: 'lookup', args: {} } }] }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata, + } +} + +async function run(generateContent: ReturnType, withTools = false) { + return (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: MODEL, + providerType: 'google', + request: { + model: MODEL, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'Look this up' }], + ...(withTools + ? { + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } + : {}), + }, + })) as ProviderResponse +} + +describe('Gemini block cost with implicit prompt caching', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + }) + + it('bills cached prompt tokens at the discounted rate, not the full input rate', async () => { + const generateContent = vi.fn().mockResolvedValue( + textTurn({ + promptTokenCount: 100_000, + cachedContentTokenCount: 80_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ) + + const response = await run(generateContent) + + expect(response.tokens).toEqual({ + input: 20_000, + output: 1_000, + cacheRead: 80_000, + total: 101_000, + }) + expect(response.cost?.input).toBeCloseTo(0.0084, 10) + expect(response.cost?.output).toBeCloseTo(0.0025, 10) + expect(response.cost?.total).toBeCloseTo(0.0109, 10) + + const cacheBlind = calculateCost(MODEL, 100_000, 1_000) + expect(cacheBlind.total).toBeCloseTo(0.0325, 10) + expect(response.cost?.total).toBeLessThan(cacheBlind.total) + }) + + it('accumulates the cached and uncached buckets separately across tool-loop turns', async () => { + const generateContent = vi + .fn() + .mockResolvedValueOnce( + toolTurn({ + promptTokenCount: 10_000, + cachedContentTokenCount: 6_000, + candidatesTokenCount: 100, + totalTokenCount: 10_100, + }) + ) + .mockResolvedValueOnce( + textTurn({ + promptTokenCount: 12_000, + cachedContentTokenCount: 9_000, + candidatesTokenCount: 200, + totalTokenCount: 12_200, + }) + ) + + const response = await run(generateContent, true) + + expect(generateContent).toHaveBeenCalledTimes(2) + expect(response.tokens).toEqual({ + input: 7_000, + output: 300, + cacheRead: 15_000, + total: 22_300, + }) + expect(response.cost?.input).toBeCloseTo(0.00255, 10) + expect(response.cost?.output).toBeCloseTo(0.00075, 10) + expect(response.cost?.total).toBeCloseTo(0.0033, 10) + }) + + it('matches plain calculateCost when the response reports no cache hit', async () => { + const generateContent = vi.fn().mockResolvedValue( + textTurn({ + promptTokenCount: 100_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ) + + const response = await run(generateContent) + + expect(response.tokens).toEqual({ + input: 100_000, + output: 1_000, + cacheRead: 0, + total: 101_000, + }) + expect(response.cost).toEqual(calculateCost(MODEL, 100_000, 1_000)) + }) +}) diff --git a/apps/sim/providers/gemini/core.streaming.test.ts b/apps/sim/providers/gemini/core.streaming.test.ts new file mode 100644 index 00000000000..c2af71ee05a --- /dev/null +++ b/apps/sim/providers/gemini/core.streaming.test.ts @@ -0,0 +1,324 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeGeminiRequest } from '@/providers/gemini/core' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers', () => ({ + MAX_TOOL_ITERATIONS: 1, +})) + +describe('executeGeminiRequest settled stream projection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps the required Gemini 2 schema extraction but does not regenerate for streaming', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: {} } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: 'unformatted answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: '{"value":"formatted"}' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 3, + candidatesTokenCount: 3, + totalTokenCount: 6, + }, + }) + const generateContentStream = vi.fn() + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream } } as never, + model: 'gemini-2.5-flash', + providerType: 'google', + request: { + model: 'gemini-2.5-flash', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + }, + })) as StreamingExecution + + expect(generateContent).toHaveBeenCalledTimes(3) + expect(generateContentStream).not.toHaveBeenCalled() + expect(generateContent.mock.calls[2][0].config).toMatchObject({ + tools: undefined, + toolConfig: undefined, + responseMimeType: 'application/json', + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: '{"value":"formatted"}', turn: 'final' }, + }) + expect(result.execution.output.content).toBe('{"value":"formatted"}') + }) + + it('runs one schema synthesis after the tool-batch cap without executing over-cap calls', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const responseSchema = { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + } + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: { batch: 1 } } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: { batch: 1 } }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: { batch: 2 } } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: { batch: 2 } }], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: '{"value":"capped"}' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 3, + candidatesTokenCount: 3, + totalTokenCount: 6, + }, + }) + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: 'gemini-2.5-flash', + providerType: 'google', + request: { + model: 'gemini-2.5-flash', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Keep looking this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { + type: 'object', + properties: { batch: { type: 'number' } }, + required: ['batch'], + }, + }, + ], + responseFormat: { + name: 'answer', + schema: responseSchema, + }, + }, + })) as StreamingExecution + + expect(generateContent).toHaveBeenCalledTimes(3) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect(mockExecuteTool).toHaveBeenCalledWith( + 'lookup', + expect.objectContaining({ batch: 1 }), + expect.any(Object) + ) + expect(generateContent.mock.calls[2][0].config).toMatchObject({ + tools: undefined, + toolConfig: undefined, + responseMimeType: 'application/json', + responseSchema, + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: '{"value":"capped"}', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + expect(result.execution.output.content).toBe('{"value":"capped"}') + expect(result.execution.output.tokens).toEqual({ + input: 6, + output: 6, + cacheRead: 0, + total: 12, + }) + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + }) + + it.each(['google', 'vertex'] as const)( + 'uses the live loop for %s streaming tool requests without a caller flag', + async (providerType) => { + mockExecuteTool.mockResolvedValue({ success: true, output: false }) + + const toolTurn = { + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: {} } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + } + const answerTurn = { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'live answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + } + const generateContent = vi.fn() + const generateContentStream = vi + .fn() + .mockResolvedValueOnce( + (async function* () { + yield toolTurn + })() + ) + .mockResolvedValueOnce( + (async function* () { + yield answerTurn + })() + ) + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream } } as never, + model: 'gemini-3-flash-preview', + providerType, + request: { + model: 'gemini-3-flash-preview', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + })) as StreamingExecution + + const events: unknown[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + + expect(generateContent).not.toHaveBeenCalled() + expect(generateContentStream).toHaveBeenCalledTimes(2) + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(result.execution.output.content).toBe('live answer') + } + ) +}) diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 1419df73782..2a9177af5f2 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -13,8 +13,11 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' +import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' import { checkForForcedToolUsage, cleanSchemaForGemini, @@ -28,6 +31,10 @@ import { mapToThinkingLevel, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError } from '@/providers/streaming-tool-loop-shared' +import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -36,7 +43,6 @@ import type { TimeSegment, } from '@/providers/types' import { - calculateCost, isDeepResearchModel, isGemini3Model, prepareToolExecution, @@ -57,20 +63,12 @@ function createInitialState( model: string, toolConfig: ToolConfig | undefined ): ExecutionState { - const initialCost = calculateCost( - model, - initialUsage.promptTokenCount, - initialUsage.candidatesTokenCount - ) + const split = splitGeminiUsage(initialUsage) return { contents, - tokens: { - input: initialUsage.promptTokenCount, - output: initialUsage.candidatesTokenCount, - total: initialUsage.totalTokenCount, - }, - cost: initialCost, + tokens: { ...split, total: initialUsage.totalTokenCount }, + cost: priceGeminiTokens(model, split), toolCalls: [], toolResults: [], iterationCount: 0, @@ -111,7 +109,7 @@ async function executeToolCallsBatch( const toolCallStartTime = Date.now() const functionCall = part.functionCall! const toolName = functionCall.name ?? '' - const args = (functionCall.args ?? {}) as Record + const args = isRecordLike(functionCall.args) ? functionCall.args : undefined const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -130,6 +128,10 @@ async function executeToolCallsBatch( } try { + if (!args) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const { toolParams, executionParams } = prepareToolExecution(tool, args, request) const result = await executeTool(toolName, executionParams, { signal: request.abortSignal, @@ -154,6 +156,10 @@ async function executeToolCallsBatch( duration, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + const toolCallEndTime = Date.now() logger.error('Error processing function call:', { error: toError(error).message, @@ -163,7 +169,7 @@ async function executeToolCallsBatch( success: false, part, toolName, - args, + args: args ?? {}, resultContent: { error: true, message: getErrorMessage(error, 'Tool execution failed'), @@ -218,8 +224,8 @@ async function executeToolCallsBatch( result: r.resultContent, }) - if (r.success && r.result?.output) { - newToolResults.push(r.result.output as Record) + if (r.success && isRecordLike(r.result?.output)) { + newToolResults.push(r.result.output) } newTimeSegments.push({ @@ -228,7 +234,7 @@ async function executeToolCallsBatch( startTime: r.startTime, endTime: r.endTime, duration: r.duration, - toolCallId: r.part.functionCall?.id ?? undefined, + toolCallId: ensureToolCallId(r.part.functionCall?.id, 'gemini'), }) totalToolsTime += r.duration @@ -269,14 +275,16 @@ function updateStateWithResponse( endTime: number ): ExecutionState { const usage = convertUsageMetadata(response.usageMetadata) - const cost = calculateCost(model, usage.promptTokenCount, usage.candidatesTokenCount) + const split = splitGeminiUsage(usage) + const cost = priceGeminiTokens(model, split) const duration = endTime - startTime return { ...state, tokens: { - input: state.tokens.input + usage.promptTokenCount, - output: state.tokens.output + usage.candidatesTokenCount, + input: state.tokens.input + split.input, + output: state.tokens.output + split.output, + cacheRead: state.tokens.cacheRead + split.cacheRead, total: state.tokens.total + usage.totalTokenCount, }, cost: { @@ -296,7 +304,6 @@ function updateStateWithResponse( duration, }, ], - iterationCount: state.iterationCount + 1, } } @@ -352,7 +359,7 @@ function createStreamingResult( output: { content: '', model: '', - tokens: state?.tokens ?? { input: 0, output: 0, total: 0 }, + tokens: state?.tokens ?? { input: 0, output: 0, cacheRead: 0, total: 0 }, toolCalls: state?.toolCalls.length ? { list: state.toolCalls, count: state.toolCalls.length } : undefined, @@ -364,7 +371,9 @@ function createStreamingResult( modelTime: state?.modelTime ?? firstResponseTime, toolsTime: state?.toolsTime ?? 0, firstResponseTime, - iterations: (state?.iterationCount ?? 0) + 1, + iterations: state + ? state.timeSegments.filter((segment) => segment.type === 'model').length + : 1, timeSegments: state?.timeSegments ?? [ { type: 'model', @@ -485,22 +494,30 @@ function extractTextFromInteractionOutputs(outputs: Interactions.Interaction['ou return textParts.join('\n\n') } +/** Token usage for one deep research interaction. */ +interface DeepResearchUsage { + inputTokens: number + outputTokens: number + reasoningTokens: number + cachedTokens: number + totalTokens: number +} + /** * Extracts token usage from an Interaction's Usage object. * The Interactions API provides total_input_tokens, total_output_tokens, total_tokens, - * and total_reasoning_tokens (for thinking models). + * total_cached_tokens, and total_reasoning_tokens (for thinking models). * * Also handles the raw API field name total_thought_tokens which the SDK may * map to total_reasoning_tokens. + * + * The Interactions API supports implicit caching, and `total_cached_tokens` is a + * subset of `total_input_tokens` there just as `cachedContentTokenCount` is of + * `promptTokenCount` on generateContent. */ -function extractInteractionUsage(usage: Interactions.Usage | undefined): { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number -} { +function extractInteractionUsage(usage: Interactions.Usage | undefined): DeepResearchUsage { if (!usage) { - return { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 } + return { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cachedTokens: 0, totalTokens: 0 } } const usageLogger = createLogger('DeepResearchUsage') @@ -512,9 +529,10 @@ function extractInteractionUsage(usage: Interactions.Usage | undefined): { usage.total_reasoning_tokens ?? ((usage as Record).total_thought_tokens as number) ?? 0 + const cachedTokens = usage.total_cached_tokens ?? 0 const totalTokens = usage.total_tokens ?? inputTokens + outputTokens - return { inputTokens, outputTokens, reasoningTokens, totalTokens } + return { inputTokens, outputTokens, reasoningTokens, cachedTokens, totalTokens } } /** @@ -523,25 +541,22 @@ function extractInteractionUsage(usage: Interactions.Usage | undefined): { function buildDeepResearchResponse( content: string, model: string, - usage: { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number - }, + usage: DeepResearchUsage, providerStartTime: number, providerStartTimeISO: string, interactionId?: string ): ProviderResponse { const providerEndTime = Date.now() const duration = providerEndTime - providerStartTime + const split = splitGeminiTokens(usage.inputTokens, usage.outputTokens, usage.cachedTokens) return { content, model, tokens: { - input: usage.inputTokens, - output: usage.outputTokens, + input: split.input, + output: split.output, + cacheRead: split.cacheRead, total: usage.totalTokens, }, timing: { @@ -562,7 +577,7 @@ function buildDeepResearchResponse( }, ], }, - cost: calculateCost(model, usage.inputTokens, usage.outputTokens), + cost: priceGeminiTokens(model, split), interactionId, } } @@ -581,20 +596,17 @@ function buildDeepResearchResponse( */ function createDeepResearchStream( stream: AsyncIterable, - onComplete?: ( - content: string, - usage: { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number - }, - interactionId?: string - ) => void + onComplete?: (content: string, usage: DeepResearchUsage, interactionId?: string) => void ): ReadableStream { const streamLogger = createLogger('DeepResearchStream') let fullContent = '' - let completionUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 } + let completionUsage: DeepResearchUsage = { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cachedTokens: 0, + totalTokens: 0, + } let completedInteractionId: string | undefined return new ReadableStream({ @@ -766,16 +778,12 @@ export async function executeDeepResearchRequest( const stream = createDeepResearchStream( streamResponse, (content, usage, streamInteractionId) => { + const split = splitGeminiTokens(usage.inputTokens, usage.outputTokens, usage.cachedTokens) + streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.inputTokens, - output: usage.outputTokens, - total: usage.totalTokens, - } + streamingResult.execution.output.tokens = { ...split, total: usage.totalTokens } streamingResult.execution.output.interactionId = streamInteractionId - - const cost = calculateCost(model, usage.inputTokens, usage.outputTokens) - streamingResult.execution.output.cost = cost + streamingResult.execution.output.cost = priceGeminiTokens(model, split) const streamEndTime = Date.now() if (streamingResult.execution.output.providerTiming) { @@ -955,8 +963,10 @@ export async function executeGeminiRequest( } // Gemini 3.x takes thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. + // includeThoughts is required for thought parts to appear; it is requested + // only on agent-events runs so legacy runs keep the pre-agent-events payload. if (request.thinkingLevel && request.thinkingLevel !== 'none') { - const thinkingConfig: ThinkingConfig = { includeThoughts: false } + const thinkingConfig: ThinkingConfig = { includeThoughts: request.agentEvents === true } if (isGemini3Model(model)) { thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel) } else { @@ -1019,7 +1029,69 @@ export async function executeGeminiRequest( } const initialCallTime = Date.now() - const shouldStream = request.stream && !tools?.length + /** + * Gemini 2 cannot combine responseSchema with tools, so structured output + * is applied on a final schema-configured request after tools settle — the + * silent path does this; the live loop would break as soon as a turn has + * no calls and skip the schema. Gemini 3 carries responseJsonSchema + * alongside tools, so its live loop keeps structured output. + */ + const hasActiveTools = Boolean(geminiConfig.tools?.length) + const responseFormatNeedsFinalPass = + Boolean(request.responseFormat) && hasActiveTools && !isGemini3Model(model) + const liveToolLoopSupported = !responseFormatNeedsFinalPass + const shouldStream = request.stream && !hasActiveTools + + // Live streaming tool loop + if (request.stream && liveToolLoopSupported && hasActiveTools) { + logger.info('Using streaming tool loop for Gemini request') + + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools ?? [] + + return createStreamingExecution({ + model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createGeminiStreamingToolLoopStream({ + ai, + model, + baseConfig: geminiConfig, + contents, + request, + logger, + timeSegments, + forcedTools, + toolConfig, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } // Streaming without tools if (shouldStream) { @@ -1042,20 +1114,19 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, - (content: string, usage: GeminiUsage) => { + (content: string, usage: GeminiUsage, thinking?: string) => { + const split = splitGeminiUsage(usage) + streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.promptTokenCount, - output: usage.candidatesTokenCount, - total: usage.totalTokenCount, - } + streamingResult.execution.output.tokens = { ...split, total: usage.totalTokenCount } + streamingResult.execution.output.cost = priceGeminiTokens(model, split) - const costResult = calculateCost( - model, - usage.promptTokenCount, - usage.candidatesTokenCount - ) - streamingResult.execution.output.cost = costResult + if (thinking) { + const segment = streamingResult.execution.output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } const streamEndTime = Date.now() if (streamingResult.execution.output.providerTiming) { @@ -1073,7 +1144,7 @@ export async function executeGeminiRequest( } ) - return { ...streamingResult, stream } + return { ...streamingResult, stream, streamFormat: 'agent-events-v1' as const } } // Non-streaming request @@ -1102,6 +1173,64 @@ export async function executeGeminiRequest( let currentResponse = response let content = '' + const generateFinalSynthesis = async ( + currentState: ExecutionState, + baseConfig: GenerateContentConfig + ): Promise<{ state: ExecutionState; response: GenerateContentResponse }> => { + const finalConfig: GenerateContentConfig = { + ...baseConfig, + tools: undefined, + toolConfig: undefined, + } + if (request.responseFormat && !isGemini3Model(model)) { + finalConfig.responseMimeType = 'application/json' + finalConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema + } + + const finalStartTime = Date.now() + const finalResponse = await ai.models.generateContent({ + model, + contents: currentState.contents, + config: finalConfig, + }) + const finalState = updateStateWithResponse( + currentState, + finalResponse, + model, + finalStartTime, + Date.now() + ) + enrichLastModelSegmentFromGeminiResponse(finalState.timeSegments, finalResponse, { + model, + }) + return { state: finalState, response: finalResponse } + } + const createSettledStreamingResult = ( + currentState: ExecutionState, + settledAnswer: string + ): StreamingExecution => { + const toolCost = sumToolCosts(currentState.toolResults) + const streamingResult = createStreamingResult( + providerStartTime, + providerStartTimeISO, + firstResponseTime, + initialCallTime, + currentState + ) + streamingResult.execution.output.model = model + streamingResult.execution.output.content = settledAnswer + streamingResult.execution.output.cost = { + ...currentState.cost, + toolCost: toolCost || undefined, + total: currentState.cost.total + toolCost, + } + + return { + ...streamingResult, + stream: createSettledAgentEventStream(settledAnswer), + streamFormat: 'agent-events-v1', + } + } // Tool execution loop const functionCalls = response.functionCalls @@ -1109,7 +1238,7 @@ export async function executeGeminiRequest( const functionNames = functionCalls.map((fc) => fc.name).join(', ') logger.info(`Received ${functionCalls.length} function call(s) from Gemini: ${functionNames}`) - while (state.iterationCount < MAX_TOOL_ITERATIONS) { + while (true) { // Extract ALL function call parts from the response (Gemini can return multiple) const functionCallParts = extractAllFunctionCallParts(currentResponse.candidates?.[0]) if (functionCallParts.length === 0) { @@ -1117,6 +1246,27 @@ export async function executeGeminiRequest( break } + if (state.iterationCount >= MAX_TOOL_ITERATIONS) { + logger.info('Gemini tool-batch cap reached; generating a tool-disabled final response') + const finalConfig = buildNextConfig( + geminiConfig, + state, + forcedTools, + request, + logger, + model + ) + const finalSynthesis = await generateFinalSynthesis(state, finalConfig) + state = finalSynthesis.state + currentResponse = finalSynthesis.response + content = extractTextContent(finalSynthesis.response.candidates?.[0]) + + if (request.stream) { + return createSettledStreamingResult(state, content) + } + break + } + const callNames = functionCallParts.map((p) => p.functionCall?.name ?? 'unknown').join(', ') logger.info( `Processing ${functionCallParts.length} function call(s): ${callNames} (iteration ${state.iterationCount + 1})` @@ -1138,95 +1288,7 @@ export async function executeGeminiRequest( state = { ...updatedState, iterationCount: updatedState.iterationCount + 1 } const nextConfig = buildNextConfig(geminiConfig, state, forcedTools, request, logger, model) - // Stream final response if requested - if (request.stream) { - const checkResponse = await ai.models.generateContent({ - model, - contents: state.contents, - config: nextConfig, - }) - state = updateStateWithResponse(state, checkResponse, model, Date.now() - 100, Date.now()) - enrichLastModelSegmentFromGeminiResponse(state.timeSegments, checkResponse, { - model, - }) - - if (checkResponse.functionCalls?.length) { - currentResponse = checkResponse - continue - } - - logger.info('No more function calls, streaming final response') - - if (request.responseFormat) { - nextConfig.tools = undefined - nextConfig.toolConfig = undefined - if (!isGemini3Model(model)) { - nextConfig.responseMimeType = 'application/json' - nextConfig.responseSchema = cleanSchemaForGemini( - request.responseFormat.schema - ) as Schema - } - } - - // Capture accumulated cost before streaming - const accumulatedCost = { - input: state.cost.input, - output: state.cost.output, - total: state.cost.total, - } - const accumulatedTokens = { ...state.tokens } - - const streamGenerator = await ai.models.generateContentStream({ - model, - contents: state.contents, - config: nextConfig, - }) - - const streamingResult = createStreamingResult( - providerStartTime, - providerStartTimeISO, - firstResponseTime, - initialCallTime, - state - ) - streamingResult.execution.output.model = model - - const stream = createReadableStreamFromGeminiStream( - streamGenerator, - (streamContent: string, usage: GeminiUsage) => { - streamingResult.execution.output.content = streamContent - streamingResult.execution.output.tokens = { - input: accumulatedTokens.input + usage.promptTokenCount, - output: accumulatedTokens.output + usage.candidatesTokenCount, - total: accumulatedTokens.total + usage.totalTokenCount, - } - - const streamCost = calculateCost( - model, - usage.promptTokenCount, - usage.candidatesTokenCount - ) - const tc = sumToolCosts(state.toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - pricing: streamCost.pricing, - } - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = new Date().toISOString() - streamingResult.execution.output.providerTiming.duration = - Date.now() - providerStartTime - } - } - ) - - return { ...streamingResult, stream } - } - - // Non-streaming: get next response + /** Resolve the final turn, then project its settled answer when streaming was requested. */ const nextModelStartTime = Date.now() const nextResponse = await ai.models.generateContent({ model, @@ -1238,6 +1300,22 @@ export async function executeGeminiRequest( model, }) currentResponse = nextResponse + + if ( + request.stream && + extractAllFunctionCallParts(nextResponse.candidates?.[0]).length === 0 + ) { + let settledResponse = nextResponse + if (responseFormatNeedsFinalPass) { + logger.info('Generating final schema-configured Gemini response') + const finalSynthesis = await generateFinalSynthesis(state, nextConfig) + state = finalSynthesis.state + settledResponse = finalSynthesis.response + } + + const settledAnswer = extractTextContent(settledResponse.candidates?.[0]) + return createSettledStreamingResult(state, settledAnswer) + } } if (!content) { @@ -1262,7 +1340,7 @@ export async function executeGeminiRequest( modelTime: state.modelTime, toolsTime: state.toolsTime, firstResponseTime, - iterations: state.iterationCount + 1, + iterations: state.timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: state.timeSegments, }, cost: state.cost, @@ -1276,6 +1354,10 @@ export async function executeGeminiRequest( stack: error instanceof Error ? error.stack : undefined, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + const enhancedError = toError(error) Object.assign(enhancedError, { timing: { @@ -1318,13 +1400,13 @@ function enrichLastModelSegmentFromGeminiResponse( Boolean(p.functionCall) ) .map((p) => ({ - id: p.functionCall.id ?? '', + id: ensureToolCallId(p.functionCall.id, 'gemini'), name: p.functionCall.name ?? '', arguments: (p.functionCall.args ?? {}) as Record, })) const usage = convertUsageMetadata(response.usageMetadata) - const cachedContentTokens = response.usageMetadata?.cachedContentTokenCount ?? 0 + const split = splitGeminiUsage(usage) const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount ?? 0 let cost: { input: number; output: number; total: number } | undefined @@ -1334,12 +1416,7 @@ function enrichLastModelSegmentFromGeminiResponse( typeof usage.promptTokenCount === 'number' && typeof usage.candidatesTokenCount === 'number' ) { - const full = calculateCost( - extras.model, - usage.promptTokenCount, - usage.candidatesTokenCount, - cachedContentTokens > 0 - ) + const full = priceGeminiTokens(extras.model, split) cost = { input: full.input, output: full.output, total: full.total } } @@ -1353,7 +1430,7 @@ function enrichLastModelSegmentFromGeminiResponse( input: usage.promptTokenCount, output: usage.candidatesTokenCount, total: usage.totalTokenCount, - ...(cachedContentTokens > 0 && { cacheRead: cachedContentTokens }), + ...(split.cacheRead > 0 && { cacheRead: split.cacheRead }), ...(thoughtsTokens > 0 && { reasoning: thoughtsTokens }), } : undefined, diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..e9b4a020f18 --- /dev/null +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -0,0 +1,383 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { resetLocalToolIdCounterForTests } from '@/providers/tool-call-id' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: vi.fn(() => ({ + toolParams: { url: 'https://httpbin.org/get' }, + executionParams: { url: 'https://httpbin.org/get' }, + })), + calculateCost: vi.fn(() => ({ + input: 0.01, + output: 0.02, + total: 0.03, + pricing: { input: 1, output: 2, updatedAt: new Date().toISOString() }, + })), + sumToolCosts: vi.fn(() => 0), + isGemini3Model: vi.fn(() => false), + shouldBillModelUsage: vi.fn(() => true), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), +})) + +describe('createGeminiStreamingToolLoopStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ + success: true, + output: { ok: true, url: 'https://httpbin.org/get' }, + }) + }) + + it('emits thinking, tool lifecycle, then final answer; allocates local tool ids', async () => { + resetLocalToolIdCounterForTests() + + const turns = [ + // Turn 1: thinking + functionCall (no id) + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { text: 'I should call the API. ', thought: true }, + { + functionCall: { + name: 'http_request', + args: { url: 'https://httpbin.org/get' }, + }, + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + } as any + })(), + // Turn 2: final answer + (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Done: https://httpbin.org/get' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 20, + candidatesTokenCount: 8, + totalTokenCount: 28, + }, + } as any + })(), + ] + + let turnIdx = 0 + const ai = { + models: { + generateContentStream: vi.fn(async () => turns[turnIdx++]), + }, + } + + const onComplete = vi.fn() + const timeSegments: any[] = [] + const stream = createGeminiStreamingToolLoopStream({ + ai: ai as any, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments, + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should call the API. ', + ]) + + const starts = events.filter((e) => e.type === 'tool_call_start') + expect(starts).toHaveLength(1) + expect(starts[0]).toMatchObject({ name: 'http_request' }) + expect((starts[0] as { id: string }).id).toMatch(/^gemini_/) + + const ends = events.filter((e) => e.type === 'tool_call_end') + expect(ends).toEqual([ + { + type: 'tool_call_end', + id: (starts[0] as { id: string }).id, + name: 'http_request', + status: 'success', + }, + ]) + + // Text streams live as `pending`; the turn_end sequence classifies turns. + const textEvents = events.filter((e) => e.type === 'text_delta') + expect(textEvents.every((e) => e.type === 'text_delta' && e.turn === 'pending')).toBe(true) + expect( + textEvents + .filter((e) => e.type === 'text_delta') + .map((e) => e.text) + .join('') + ).toContain('Done:') + expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ + 'intermediate', + 'final', + ]) + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Done:'), + toolCalls: expect.objectContaining({ count: 1 }), + }) + ) + }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const ai = { + models: { + generateContentStream: vi.fn(async () => + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'http_request', + args: { url: 'https://httpbin.org/get' }, + }, + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + } as any + })() + ), + }, + } + const onComplete = vi.fn() + const stream = createGeminiStreamingToolLoopStream({ + ai: ai as any, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 10, output: 5, cacheRead: 0, total: 15 } }) + ) + }) + + it('overrides the turn signal and aborts the active SDK call on consumer cancellation', async () => { + const baseAbortController = new AbortController() + const requestAbortController = new AbortController() + let capturedSignal: AbortSignal | undefined + let resolveCallStarted: (() => void) | undefined + const callStarted = new Promise((resolve) => { + resolveCallStarted = resolve + }) + const generateContentStream = vi.fn( + async ({ config }: { config: { abortSignal?: AbortSignal } }) => { + capturedSignal = config.abortSignal + resolveCallStarted?.() + return await new Promise((_, reject) => { + config.abortSignal?.addEventListener( + 'abort', + () => reject(new DOMException('SDK request aborted', 'AbortError')), + { once: true } + ) + }) + } + ) + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: { abortSignal: baseAbortController.signal }, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + abortSignal: requestAbortController.signal, + } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete: vi.fn(), + }) + const reader = stream.getReader() + const pendingRead = reader.read() + + await callStarted + expect(capturedSignal).toBeDefined() + expect(capturedSignal).not.toBe(baseAbortController.signal) + expect(capturedSignal).not.toBe(requestAbortController.signal) + expect(capturedSignal?.aborted).toBe(false) + + await reader.cancel('consumer cancelled') + + expect(capturedSignal?.aborted).toBe(true) + expect(capturedSignal?.reason).toBe('consumer cancelled') + await expect(pendingRead).resolves.toEqual({ done: true, value: undefined }) + }) + + it('accepts terminal MAX_TOKENS text when no function call is pending', async () => { + const generateContentStream = vi.fn(async () => + (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'Complete answer at the token limit' }] }, + finishReason: 'MAX_TOKENS', + }, + ], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 6, + totalTokenCount: 10, + }, + } as any + })() + ) + const onComplete = vi.fn() + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'answer fully' }] }], + request: { model: 'gemini-2.5-flash' } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: 'Complete answer at the token limit', + iterations: 1, + }) + ) + }) + + it.each([ + { + label: 'complete', + parts: [ + { text: 'Partial answer' }, + { functionCall: { name: 'http_request', args: { url: 'https://httpbin.org/get' } } }, + ], + }, + { + label: 'partial', + parts: [{ text: 'Partial answer' }, { functionCall: { args: {} } }], + }, + ])('rejects a $label function call on a MAX_TOKENS turn', async ({ parts }) => { + const generateContentStream = vi.fn(async () => + (async function* () { + yield { + candidates: [{ content: { parts }, finishReason: 'MAX_TOKENS' }], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 6, + totalTokenCount: 10, + }, + } as any + })() + ) + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'answer fully' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete: vi.fn(), + }) + + await expect(collectEvents(stream)).rejects.toThrow( + 'Gemini stream ended with finish reason MAX_TOKENS' + ) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts new file mode 100644 index 00000000000..f3270a5471e --- /dev/null +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -0,0 +1,651 @@ +/** + * Live Gemini streaming tool loop. + * + * Each model turn uses generateContentStream. Thought parts → thinking_delta + * live; functionCall parts → tool_call_start (with local ids when the model + * omits them); text parts → `pending` text deltas live, classified by a + * `turn_end` event as intermediate vs final. Tool ends emit in completion + * order; abort → cancelled. + * + * Function-call parts are echoed back into request history verbatim — Google + * requires signatures/ids to round-trip exactly as received, so local ids are + * used only for agent events and trace segments, never injected into history. + */ + +import { + type Content, + FunctionCallingConfigMode, + type GenerateContentConfig, + type GenerateContentResponse, + type GoogleGenAI, + type Part, + type Schema, + type ToolConfig, +} from '@google/genai' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + checkForForcedToolUsage, + cleanSchemaForGemini, + convertUsageMetadata, + ensureStructResponse, +} from '@/providers/google/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + type StreamingToolLoopComplete, + settleOpenTools, + terminateToolLoop, +} from '@/providers/streaming-tool-loop-shared' +import { ensureToolCallId } from '@/providers/tool-call-id' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ModelPricing, ProviderRequest, TimeSegment } from '@/providers/types' +import { isGemini3Model, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' +import type { GeminiUsage } from './types' +import { priceGeminiTokens, splitGeminiUsage } from './usage' + +type GeminiStreamingToolLoopComplete = Omit & { + tokens: { input: number; output: number; cacheRead: number; total: number } +} + +export interface CreateGeminiStreamingToolLoopStreamOptions { + ai: GoogleGenAI + model: string + baseConfig: GenerateContentConfig + contents: Content[] + request: ProviderRequest + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + toolConfig?: ToolConfig + onComplete: (result: GeminiStreamingToolLoopComplete) => void +} + +/** + * A streamed functionCall part paired with the execution-local id used on the + * agent-events stream. The part itself stays verbatim for history echo. + */ +interface StreamedFunctionCall { + part: Part + localId: string +} + +function buildNextConfig( + baseConfig: GenerateContentConfig, + currentToolConfig: ToolConfig | undefined, + usedForcedTools: string[], + forcedTools: string[], + request: ProviderRequest, + logger: Logger, + model: string +): GenerateContentConfig { + const nextConfig = { ...baseConfig } + const allForcedToolsUsed = forcedTools.length > 0 && usedForcedTools.length === forcedTools.length + + if (allForcedToolsUsed && request.responseFormat) { + nextConfig.tools = undefined + nextConfig.toolConfig = undefined + if (isGemini3Model(model)) { + logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set') + } else { + nextConfig.responseMimeType = 'application/json' + nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema + logger.info('Using structured output for final response after tool execution') + } + } else if (currentToolConfig) { + nextConfig.toolConfig = currentToolConfig + } else { + nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } } + } + + return nextConfig +} + +/** + * Drain one generateContentStream turn into live agent events + aggregated parts. + */ +async function drainGeminiTurn( + stream: AsyncGenerator, + controller: ReadableStreamDefaultController, + openTools: Map, + onIteratorChange: ( + iterator: AsyncIterator | undefined + ) => void = () => {} +): Promise<{ + text: string + thinking: string + functionCalls: StreamedFunctionCall[] + hasFunctionCallPart: boolean + usage: GeminiUsage + finishReason?: string +}> { + let text = '' + let thinking = '' + const functionCalls: StreamedFunctionCall[] = [] + let hasFunctionCallPart = false + const seenKeys = new Set() + let usage: GeminiUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + cachedContentTokenCount: 0, + totalTokenCount: 0, + } + let finishReason: string | undefined + + const iterator = stream[Symbol.asyncIterator]() + onIteratorChange(iterator) + try { + while (true) { + const next = await iterator.next() + if (next.done) break + const chunk = next.value + if (chunk.promptFeedback?.blockReason) { + throw new Error( + `Gemini prompt blocked: ${chunk.promptFeedback.blockReason}${ + chunk.promptFeedback.blockReasonMessage + ? ` (${chunk.promptFeedback.blockReasonMessage})` + : '' + }` + ) + } + if (chunk.usageMetadata) { + usage = convertUsageMetadata(chunk.usageMetadata) + } + + const candidate = chunk.candidates?.[0] + if (candidate?.finishReason) { + finishReason = String(candidate.finishReason) + } + + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + const fallback = chunk.text + if (fallback) { + text += fallback + controller.enqueue({ type: 'text_delta', text: fallback, turn: 'pending' }) + } + continue + } + + for (const part of parts) { + if (part.functionCall) { + hasFunctionCallPart = true + const localId = ensureToolCallId(part.functionCall.id, 'gemini') + const name = part.functionCall.name ?? '' + if (!seenKeys.has(localId) && name) { + seenKeys.add(localId) + functionCalls.push({ part, localId }) + if (!openTools.has(localId)) { + openTools.set(localId, name) + controller.enqueue({ type: 'tool_call_start', id: localId, name }) + } + } + continue + } + + if (!part.text) continue + if (part.thought === true) { + thinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + text += part.text + // Live pending text: sinks render it now; the pump projects it to the + // answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: part.text, turn: 'pending' }) + } + } + } + } finally { + onIteratorChange(undefined) + } + + return { text, thinking, functionCalls, hasFunctionCallPart, usage, finishReason } +} + +/** + * Multi-turn Gemini tool loop as an agent-events-v1 object stream. + */ +export function createGeminiStreamingToolLoopStream( + options: CreateGeminiStreamingToolLoopStreamOptions +): ReadableStream { + const { + ai, + model, + baseConfig, + contents: initialContents, + request, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeStreamIterator: AsyncIterator | undefined + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } + + return new ReadableStream({ + start(controller) { + void (async () => { + let contents = [...initialContents] + let currentToolConfig = options.toolConfig + let usedForcedTools: string[] = [] + + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, cacheRead: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ModelPricing | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } + + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + if (!consumerCancelled) { + settleOpenTools(controller, openToolStarts, 'cancelled') + } + throw new DOMException('Stream aborted', 'AbortError') + } + + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + const turnConfig = finalSynthesis + ? { ...baseConfig, tools: undefined, toolConfig: undefined } + : buildNextConfig( + baseConfig, + currentToolConfig, + usedForcedTools, + forcedTools, + request, + logger, + model + ) + + const modelStart = Date.now() + const streamGenerator = await ai.models.generateContentStream({ + model, + contents, + config: { + ...turnConfig, + abortSignal: loopAbortController.signal, + }, + }) + + const drained = await drainGeminiTurn( + streamGenerator, + controller, + openToolStarts, + (iterator) => { + activeStreamIterator = iterator + } + ) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + const split = splitGeminiUsage(drained.usage) + tokens.input += split.input + tokens.output += split.output + tokens.cacheRead += split.cacheRead + tokens.total += drained.usage.totalTokenCount + + const turnCost = priceGeminiTokens(model, split) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + const isCompleteTurn = + drained.finishReason === 'STOP' || + (drained.finishReason === 'MAX_TOKENS' && + Boolean(drained.text) && + !drained.hasFunctionCallPart) + if (!isCompleteTurn) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Gemini stream ended with finish reason ${drained.finishReason ?? 'missing'}` + ) + } + if (finalSynthesis && drained.functionCalls.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Gemini returned function calls during final synthesis') + } + + const turnTag = drained.functionCalls.length > 0 ? 'intermediate' : 'final' + controller.enqueue({ type: 'turn_end', turn: turnTag }) + content = drained.text + + const toolCallsForEnrich: IterationToolCall[] = drained.functionCalls + .filter((fc) => Boolean(fc.part.functionCall)) + .map((fc) => ({ + id: fc.localId, + name: fc.part.functionCall?.name ?? '', + arguments: (fc.part.functionCall?.args ?? {}) as Record, + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + thinkingContent: drained.thinking || undefined, + toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined, + finishReason: drained.finishReason, + tokens: { + input: drained.usage.promptTokenCount, + output: drained.usage.candidatesTokenCount, + total: drained.usage.totalTokenCount, + ...(split.cacheRead > 0 && { cacheRead: split.cacheRead }), + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'google', + }) + + const forcedCheck = checkForForcedToolUsage( + drained.functionCalls + .map((fc) => fc.part.functionCall) + .filter((fc): fc is NonNullable => Boolean(fc)), + currentToolConfig, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + usedForcedTools = forcedCheck.usedForcedTools + currentToolConfig = forcedCheck.nextToolConfig + } + + if (drained.functionCalls.length === 0) { + sawFinalTurn = true + break + } + + const toolsStartTime = Date.now() + + const orderedResults = await Promise.all( + drained.functionCalls.map(async ({ part, localId }) => { + const functionCall = part.functionCall! + const toolCallId = localId + const toolName = functionCall.name ?? '' + const toolArgs = isRecordLike(functionCall.args) ? functionCall.args : undefined + const toolCallStartTime = Date.now() + + try { + if (loopAbortController.signal.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + part, + toolCallId, + toolName, + toolArgs, + toolParams: {} as Record, + resultContent: { + error: true, + message: `Tool ${toolName} not found`, + tool: toolName, + }, + result: undefined as + | { success: boolean; output?: unknown; error?: string } + | undefined, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + success: false, + } + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: loopAbortController.signal, + }) + const toolCallEndTime = Date.now() + const resultContent: Record = result.success + ? ensureStructResponse(result.output) + : { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { + part, + toolCallId, + toolName, + toolArgs, + toolParams, + resultContent, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: result.success, + } + } catch (error) { + const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolCallId) + if (!consumerCancelled) { + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'cancelled', + }) + } + throw error + } + if (isAbortError(error)) { + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing function call:', { + error: toError(error).message, + functionName: toolName, + }) + const status: ToolCallEndStatus = 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { + part, + toolCallId, + toolName, + toolArgs: toolArgs ?? {}, + toolParams: {} as Record, + resultContent: { + error: true, + message: getErrorMessage(error, 'Tool execution failed'), + tool: toolName, + }, + result: undefined, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: false, + } + } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + /** + * Echo the model's functionCall parts verbatim (signatures and any + * model-provided ids must round-trip untouched). A functionResponse + * id is attached only when the model itself provided one. + */ + const modelParts: Part[] = orderedResults.map((r) => r.part) + const userParts: Part[] = orderedResults.map((r) => ({ + functionResponse: { + name: r.toolName, + response: r.resultContent, + ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), + }, + })) + + contents = [ + ...contents, + { role: 'model', parts: modelParts }, + { role: 'user', parts: userParts }, + ] + + for (const r of orderedResults) { + toolCalls.push({ + name: r.toolName, + arguments: r.toolParams, + startTime: new Date(r.startTime).toISOString(), + endTime: new Date(r.endTime).toISOString(), + duration: r.duration, + result: r.resultContent, + success: r.success, + }) + if ( + r.success && + r.result?.output && + typeof r.result.output === 'object' && + !Array.isArray(r.result.output) + ) { + toolResults.push(r.result.output as Record) + } + timeSegments.push({ + type: 'tool', + name: r.toolName, + startTime: r.startTime, + endTime: r.endTime, + duration: r.duration, + toolCallId: r.toolCallId, + }) + } + + iterationCount += 1 + } + + if (!sawFinalTurn) { + throw new Error('Gemini tool loop ended without a final response') + } + + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Gemini streaming tool loop failed', { + error: toError(cause).message, + }), + }) + } finally { + activeStreamIterator = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) + } + })().catch((error) => { + // `start` cannot be async (the loop must not block the first pull), so + // a throw escaping the IIFE would surface as an unhandled rejection. + logger.error('Unhandled failure in Gemini streaming tool loop', { + error: toError(error).message, + }) + }) + }, + async cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + await activeStreamIterator?.return?.() + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, + }) +} diff --git a/apps/sim/providers/gemini/types.ts b/apps/sim/providers/gemini/types.ts index e74b69fe44a..b605e835a78 100644 --- a/apps/sim/providers/gemini/types.ts +++ b/apps/sim/providers/gemini/types.ts @@ -2,11 +2,17 @@ import type { Content, ToolConfig } from '@google/genai' import type { FunctionCallResponse, ModelPricing, TimeSegment } from '@/providers/types' /** - * Usage metadata from Gemini responses + * Usage metadata from Gemini responses. + * + * `cachedContentTokenCount` is a SUBSET of `promptTokenCount`, not a sibling of + * it — Gemini counts implicitly cached prompt tokens inside the prompt total and + * only discounts their rate. Anything pricing this usage must subtract it out + * before charging the base input rate. */ export interface GeminiUsage { promptTokenCount: number candidatesTokenCount: number + cachedContentTokenCount: number totalTokenCount: number } @@ -23,7 +29,8 @@ interface ParsedFunctionCall { */ export interface ExecutionState { contents: Content[] - tokens: { input: number; output: number; total: number } + /** `input` excludes `cacheRead`; `total` counts both, plus output. */ + tokens: { input: number; output: number; cacheRead: number; total: number } cost: { input: number; output: number; total: number; pricing: ModelPricing } toolCalls: FunctionCallResponse[] toolResults: Record[] diff --git a/apps/sim/providers/gemini/usage.test.ts b/apps/sim/providers/gemini/usage.test.ts new file mode 100644 index 00000000000..bcaf8644035 --- /dev/null +++ b/apps/sim/providers/gemini/usage.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' +import { calculateCost } from '@/providers/utils' + +/** input 0.30/M, cachedInput 0.03/M, output 2.50/M. */ +const MODEL = 'gemini-2.5-flash' + +describe('splitGeminiTokens', () => { + it('subtracts the cached subset out of the prompt total', () => { + expect(splitGeminiTokens(100_000, 1_000, 80_000)).toEqual({ + input: 20_000, + output: 1_000, + cacheRead: 80_000, + }) + }) + + it('leaves the prompt total intact when nothing was cached', () => { + expect(splitGeminiTokens(100_000, 1_000, 0)).toEqual({ + input: 100_000, + output: 1_000, + cacheRead: 0, + }) + }) + + it('never bills more tokens than the prompt contained when cached is over-reported', () => { + // Cached is a subset of the prompt total, so it can never exceed it. + // Leaving it unclamped would bill 4,000 cached tokens on a 1,000-token prompt. + expect(splitGeminiTokens(1_000, 10, 4_000)).toEqual({ + input: 0, + output: 10, + cacheRead: 1_000, + }) + expect(splitGeminiTokens(1_000, 10, -5)).toEqual({ + input: 1_000, + output: 10, + cacheRead: 0, + }) + }) +}) + +describe('splitGeminiUsage', () => { + it('reads the cached subset off the generateContent usage shape', () => { + expect( + splitGeminiUsage({ + promptTokenCount: 50_000, + candidatesTokenCount: 500, + cachedContentTokenCount: 30_000, + totalTokenCount: 50_500, + }) + ).toEqual({ input: 20_000, output: 500, cacheRead: 30_000 }) + }) +}) + +describe('priceGeminiTokens', () => { + it('charges cached tokens at the discounted rate instead of the base input rate', () => { + const cost = priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 80_000)) + + expect(cost.input).toBeCloseTo(20_000 * 0.0000003 + 80_000 * 0.00000003, 10) + expect(cost.output).toBeCloseTo(1_000 * 0.0000025, 10) + expect(cost.total).toBeCloseTo(0.0109, 10) + }) + + it('costs strictly less than pricing the whole prompt total at the base rate', () => { + const cacheBlind = calculateCost(MODEL, 100_000, 1_000) + const cacheAware = priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 80_000)) + + expect(cacheBlind.total).toBeCloseTo(0.0325, 10) + expect(cacheAware.total).toBeLessThan(cacheBlind.total) + }) + + it('matches plain calculateCost exactly when there is no cache hit', () => { + expect(priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 0))).toEqual( + calculateCost(MODEL, 100_000, 1_000) + ) + }) +}) diff --git a/apps/sim/providers/gemini/usage.ts b/apps/sim/providers/gemini/usage.ts new file mode 100644 index 00000000000..f79f11d2ec9 --- /dev/null +++ b/apps/sim/providers/gemini/usage.ts @@ -0,0 +1,62 @@ +import { LIST_PRICE_POLICY, type PricedModelCost, priceModelUsage } from '@/providers/cost-policy' +import type { GeminiUsage } from '@/providers/gemini/types' + +/** + * One Gemini response's tokens split into the buckets that price differently. + * `input` is the uncached remainder, so `input + cacheRead` is the prompt total + * Gemini reported. + */ +export interface GeminiTokenSplit { + input: number + output: number + cacheRead: number +} + +/** + * Splits a cache-inclusive Gemini prompt total into the uncached remainder and + * the cache read. + * + * Gemini's implicit context cache (on by default from 2.5 onward) reports cached + * tokens as a subset of the prompt total — `cachedContentTokenCount` inside + * `promptTokenCount` on generateContent, `total_cached_tokens` inside + * `total_input_tokens` on the Interactions API. Charging the prompt total at the + * base input rate therefore bills cache hits at full price; the subtraction is + * what routes them to the model's discounted `cachedInput` rate instead. + */ +export function splitGeminiTokens( + promptTokens: number, + candidatesTokens: number, + cachedTokens: number +): GeminiTokenSplit { + const prompt = Math.max(0, promptTokens) + // Clamped to the prompt total: a payload reporting more cached tokens than it + // processed would otherwise bill more input than the request contained. + const cacheRead = Math.min(Math.max(0, cachedTokens), prompt) + + return { + input: prompt - cacheRead, + output: candidatesTokens, + cacheRead, + } +} + +/** {@link splitGeminiTokens} for the generateContent usage shape. */ +export function splitGeminiUsage(usage: GeminiUsage): GeminiTokenSplit { + return splitGeminiTokens( + usage.promptTokenCount, + usage.candidatesTokenCount, + usage.cachedContentTokenCount + ) +} + +/** + * Prices a split through the shared cache-aware pricing function. With no cache + * hit this matches pricing the whole prompt total at the base input rate. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function priceGeminiTokens(model: string, split: GeminiTokenSplit): PricedModelCost { + return priceModelUsage(model, split, LIST_PRICE_POLICY) +} diff --git a/apps/sim/providers/google/utils.stream.test.ts b/apps/sim/providers/google/utils.stream.test.ts new file mode 100644 index 00000000000..1b2dfaa1c79 --- /dev/null +++ b/apps/sim/providers/google/utils.stream.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromGeminiStream } from '@/providers/google/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromGeminiStream', () => { + it('splits thought parts into thinking_delta and answer into text_delta', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { text: 'Reasoning step. ', thought: true }, + { text: 'Final answer.', thought: false }, + ], + }, + }, + ], + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 7, + totalTokenCount: 12, + }, + } as any + })(), + onComplete + ) + + const events = await collectEvents(stream) + expect(events).toEqual([ + { type: 'thinking_delta', text: 'Reasoning step. ' }, + { type: 'text_delta', text: 'Final answer.', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + 'Final answer.', + expect.objectContaining({ promptTokenCount: 5 }), + 'Reasoning step. ' + ) + }) + + it('does not invent thinking when only answer text is present', async () => { + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + text: 'Just text', + candidates: [{ content: { parts: [{ text: 'Just text' }] } }], + } as any + })() + ) + const events = await collectEvents(stream) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect( + events + .filter((e) => e.type === 'text_delta') + .map((e) => e.text) + .join('') + ).toContain('Just text') + }) + + it('surfaces blocked prompts instead of completing an empty stream', async () => { + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + promptFeedback: { + blockReason: 'SAFETY', + blockReasonMessage: 'Prompt violated safety policy', + }, + } as any + })() + ) + + await expect(collectEvents(stream)).rejects.toThrow( + 'Gemini prompt blocked: SAFETY (Prompt violated safety policy)' + ) + }) +}) diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index 7cd7fff8277..ead73247543 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -4,12 +4,57 @@ import { describe, expect, it } from 'vitest' import { convertToGeminiFormat, + convertUsageMetadata, ensureStructResponse, mapToThinkingBudget, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' import type { ProviderRequest } from '@/providers/types' +describe('convertUsageMetadata', () => { + it('carries the cached prompt subset through so callers can discount it', () => { + expect( + convertUsageMetadata({ + promptTokenCount: 100_000, + cachedContentTokenCount: 80_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ).toEqual({ + promptTokenCount: 100_000, + candidatesTokenCount: 1_000, + cachedContentTokenCount: 80_000, + totalTokenCount: 101_000, + }) + }) + + it('reports no cache hit when the field is absent or the metadata is missing', () => { + expect( + convertUsageMetadata({ + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }).cachedContentTokenCount + ).toBe(0) + expect(convertUsageMetadata(undefined).cachedContentTokenCount).toBe(0) + }) + + it('keeps the cached count a subset of the tool-use-inclusive prompt total', () => { + const usage = convertUsageMetadata({ + promptTokenCount: 8_000, + toolUsePromptTokenCount: 2_000, + cachedContentTokenCount: 6_000, + candidatesTokenCount: 100, + thoughtsTokenCount: 40, + totalTokenCount: 10_140, + }) + + expect(usage.promptTokenCount).toBe(10_000) + expect(usage.candidatesTokenCount).toBe(140) + expect(usage.cachedContentTokenCount).toBeLessThan(usage.promptTokenCount) + }) +}) + describe('mapToThinkingBudget', () => { it('maps named levels to a within-range budget for gemini-2.5-pro (128-32768, cannot disable)', () => { expect(mapToThinkingBudget('gemini-2.5-pro', 'low')).toBeGreaterThanOrEqual(128) diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 05aa74328f7..63822da75d0 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -16,6 +16,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { buildGeminiMessageParts } from '@/providers/attachments' +import type { GeminiUsage } from '@/providers/gemini/types' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest } from '@/providers/types' import { trackForcedToolUsage } from '@/providers/utils' @@ -37,23 +39,6 @@ export function ensureStructResponse(value: unknown): Record { return { value } } -/** - * Usage metadata for Google Gemini responses - */ -export interface GeminiUsage { - promptTokenCount: number - candidatesTokenCount: number - totalTokenCount: number -} - -/** - * Parsed function call from Gemini response - */ -interface ParsedFunctionCall { - name: string - args: Record -} - /** * Removes additionalProperties from a schema object (not supported by Gemini) */ @@ -92,40 +77,6 @@ export function extractTextContent(candidate: Candidate | undefined): string { return textParts.map((part) => part.text).join('\n') } -/** - * Extracts the first function call from a Gemini response candidate - */ -export function extractFunctionCall(candidate: Candidate | undefined): ParsedFunctionCall | null { - if (!candidate?.content?.parts) return null - - for (const part of candidate.content.parts) { - if (part.functionCall) { - return { - name: part.functionCall.name ?? '', - args: (part.functionCall.args ?? {}) as Record, - } - } - } - - return null -} - -/** - * Extracts the full Part containing the function call (preserves thoughtSignature) - * @deprecated Use extractAllFunctionCallParts for proper multi-tool handling - */ -export function extractFunctionCallPart(candidate: Candidate | undefined): Part | null { - if (!candidate?.content?.parts) return null - - for (const part of candidate.content.parts) { - if (part.functionCall) { - return part - } - } - - return null -} - /** * Extracts ALL Parts containing function calls from a candidate. * Gemini can return multiple function calls in a single response, @@ -141,6 +92,10 @@ export function extractAllFunctionCallParts(candidate: Candidate | undefined): P * Converts usage metadata from SDK response to our format. * Per Gemini docs, total = promptTokenCount + candidatesTokenCount + toolUsePromptTokenCount + thoughtsTokenCount * We include toolUsePromptTokenCount in input and thoughtsTokenCount in output for correct billing. + * + * `cachedContentTokenCount` is carried through unchanged — it stays a subset of + * the reported `promptTokenCount`, and callers subtract it when they need the + * tokens billed at the base input rate. */ export function convertUsageMetadata( usageMetadata: GenerateContentResponseUsageMetadata | undefined @@ -152,6 +107,7 @@ export function convertUsageMetadata( return { promptTokenCount, candidatesTokenCount, + cachedContentTokenCount: usageMetadata?.cachedContentTokenCount ?? 0, totalTokenCount: usageMetadata?.totalTokenCount ?? 0, } } @@ -280,39 +236,85 @@ export function convertToGeminiFormat( } /** - * Creates a ReadableStream from a Google Gemini streaming response + * Creates an agent-events-v1 stream from a Google Gemini streaming response. + * Thought parts (`part.thought === true`) → thinking_delta; other text → text_delta. + * Capability-honest: thinking only appears when includeThoughts was requested. */ export function createReadableStreamFromGeminiStream( stream: AsyncGenerator, - onComplete?: (content: string, usage: GeminiUsage) => void -): ReadableStream { + onComplete?: (content: string, usage: GeminiUsage, thinking?: string) => void +): ReadableStream { let fullContent = '' - let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } + let fullThinking = '' + let usage: GeminiUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + cachedContentTokenCount: 0, + totalTokenCount: 0, + } + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { try { - for await (const chunk of stream) { + streamIterator = stream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const chunk = next.value + if (chunk.promptFeedback?.blockReason) { + throw new Error( + `Gemini prompt blocked: ${chunk.promptFeedback.blockReason}${ + chunk.promptFeedback.blockReasonMessage + ? ` (${chunk.promptFeedback.blockReasonMessage})` + : '' + }` + ) + } if (chunk.usageMetadata) { usage = convertUsageMetadata(chunk.usageMetadata) } + const parts = chunk.candidates?.[0]?.content?.parts + if (Array.isArray(parts)) { + for (const part of parts) { + if (!part.text) continue + if (part.thought === true) { + fullThinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + fullContent += part.text + controller.enqueue({ type: 'text_delta', text: part.text, turn: 'final' }) + } + } + continue + } + + // Fallback when parts are not exposed — answer text only (no false thinking). const text = chunk.text if (text) { fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + controller.enqueue({ type: 'text_delta', text, turn: 'final' }) } } - onComplete?.(fullContent, usage) + if (cancelled) return + onComplete?.(fullContent, usage, fullThinking || undefined) controller.close() } catch (error) { - logger.error('Error reading Google Gemini stream', { - error: toError(error).message, - }) - controller.error(error) + if (!cancelled) { + logger.error('Error reading Google Gemini stream', { + error: toError(error).message, + }) + controller.error(error) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts new file mode 100644 index 00000000000..7cf12b31de0 --- /dev/null +++ b/apps/sim/providers/groq/index.test.ts @@ -0,0 +1,180 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate, mockExecuteTool, mockPrepareToolsWithUsageControl } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockPrepareToolsWithUsageControl: vi.fn(), +})) + +vi.mock('groq-sdk', () => ({ + Groq: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => ['groq/openai/gpt-oss-120b']), + getProviderDefaultModel: vi.fn(() => 'groq/openai/gpt-oss-120b'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/groq/utils', () => ({ + createReadableStreamFromGroqStream: vi.fn(), +})) + +vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ + createOpenAICompatStreamingToolLoopStream: vi.fn(), +})) + +vi.mock('@/providers/streaming-execution', () => ({ + createStreamingExecution: vi.fn((args) => args), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +import { groqProvider } from '@/providers/groq/index' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'groq/openai/gpt-oss-120b', + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hi' }], + ...overrides, + } +} + +describe('groqProvider reasoning payload', () => { + beforeEach(() => { + mockCreate.mockReset() + mockExecuteTool.mockReset() + mockPrepareToolsWithUsageControl.mockReset() + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }) + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it('GPT-OSS sets include_reasoning and reasoning_effort', async () => { + await groqProvider.executeRequest( + request({ model: 'groq/openai/gpt-oss-120b', reasoningEffort: 'high' }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.model).toBe('openai/gpt-oss-120b') + expect(payload.include_reasoning).toBe(true) + expect(payload.reasoning_effort).toBe('high') + expect(payload.reasoning_format).toBeUndefined() + }) + + it('GPT-OSS sends no reasoning params when effort and thinking are unset (legacy request shape)', async () => { + await groqProvider.executeRequest(request({ model: 'groq/openai/gpt-oss-20b' })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.include_reasoning).toBeUndefined() + expect(payload.reasoning_effort).toBeUndefined() + }) + + it('GPT-OSS defaults reasoning_effort to medium when only a thinking level is set', async () => { + await groqProvider.executeRequest( + request({ model: 'groq/openai/gpt-oss-20b', thinkingLevel: 'enabled' }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.include_reasoning).toBe(true) + expect(payload.reasoning_effort).toBe('medium') + }) + + it('Qwen sets reasoning_format parsed when thinking enabled', async () => { + await groqProvider.executeRequest( + request({ + model: 'groq/qwen/qwen3-32b', + thinkingLevel: 'enabled', + }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_format).toBe('parsed') + expect(payload.include_reasoning).toBeUndefined() + }) + + it('Qwen disables reasoning via reasoning_effort none when thinking is none', async () => { + await groqProvider.executeRequest( + request({ + model: 'groq/qwen/qwen3.6-27b', + thinkingLevel: 'none', + }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_format).toBeUndefined() + expect(payload.reasoning_effort).toBe('none') + }) + + it('selects the live tool loop without a caller flag', async () => { + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [ + { + type: 'function', + function: { name: 'lookup', description: 'Lookup', parameters: {} }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + vi.mocked(createOpenAICompatStreamingToolLoopStream).mockReturnValue( + new ReadableStream() as never + ) + + const result = (await groqProvider.executeRequest( + request({ + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + )) as unknown as { + createStream: (handles: { + output: { content?: string } + finalizeTiming: () => void + }) => ReadableStream + } + + const output: { content?: string } = {} + result.createStream({ output, finalizeTiming: vi.fn() }) + + expect(createOpenAICompatStreamingToolLoopStream).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 15d854e145c..8185c4b3de6 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -1,12 +1,20 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { Groq } from 'groq-sdk' -import type { StreamingExecution } from '@/executor/types' +import type { ChatCompletionCreateParamsStreaming as GroqChatCompletionCreateParamsStreaming } from 'groq-sdk/resources/chat/completions' +import type { + ChatCompletionChunk, + ChatCompletionMessageParam, +} from 'openai/resources/chat/completions' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -20,7 +28,6 @@ import { calculateCost, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -77,6 +84,27 @@ export const groqProvider: ProviderConfig = { if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + /** + * Groq reasoning: GPT-OSS uses include_reasoning + reasoning_effort; Qwen + * uses reasoning_format: parsed (compatible with tools) and disables via + * reasoning_effort: none. Reasoning params are only sent when the user set + * a thinking level or an explicit effort — otherwise the request keeps the + * legacy shape (Groq's server defaults already match what would be sent). + */ + const groqModelId = payload.model as string + const isGptOss = groqModelId.includes('gpt-oss') + const isQwenReasoning = /qwen3/i.test(groqModelId) + const hasExplicitEffort = Boolean(request.reasoningEffort && request.reasoningEffort !== 'auto') + const hasThinkingLevel = Boolean(request.thinkingLevel && request.thinkingLevel !== 'none') + if (isGptOss && (hasExplicitEffort || hasThinkingLevel)) { + payload.include_reasoning = true + payload.reasoning_effort = hasExplicitEffort ? request.reasoningEffort : 'medium' + } else if (isQwenReasoning && hasThinkingLevel) { + payload.reasoning_format = 'parsed' + } else if (isQwenReasoning && request.thinkingLevel === 'none') { + payload.reasoning_effort = 'none' + } + if (request.responseFormat) { payload.response_format = { type: 'json_schema', @@ -112,7 +140,68 @@ export const groqProvider: ProviderConfig = { } } - if (request.stream && (!tools || tools.length === 0)) { + if (request.stream && payload.tools?.length) { + logger.info('Using streaming tool loop for Groq request') + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Groq', + request, + basePayload: payload, + // double-cast-allowed: formatMessagesForProvider returns loosely-typed provider messages that are wire-compatible with the OpenAI chat.completions message params the shared loop expects + messages: formattedMessages as unknown as ChatCompletionMessageParam[], + createStream: async (params, options) => { + const groqParams = { + ...params, + stream: true, + // double-cast-allowed: groq-sdk chat params are wire-compatible with the OpenAI-typed payload built by the shared compat tool loop + } as unknown as GroqChatCompletionCreateParamsStreaming + const stream = await groq.chat.completions.create(groqParams, options) + // double-cast-allowed: groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the shared compat loop consumes + return stream as unknown as AsyncIterable + }, + logger, + timeSegments, + forcedTools, + preserveAssistantReasoning: true, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + + if (request.stream && !payload.tools?.length) { logger.info('Using streaming response for Groq request (no tools)') const providerStartTime = Date.now() @@ -134,26 +223,39 @@ export const groqProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromGroqStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes + streamResponse as unknown as AsyncIterable, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } + finalizeTiming() } - }), + ), }) return streamingResult @@ -220,10 +322,25 @@ export const groqProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -241,6 +358,9 @@ export const groqProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -260,11 +380,14 @@ export const groqProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + const assistantReasoning = (assistantMessage as { reasoning?: string } | undefined) + ?.reasoning currentMessages.push({ role: 'assistant', - content: null, + content: assistantMessage?.content ?? '', tool_calls: toolCallsInResponse.map((tc) => ({ id: tc.id, type: 'function', @@ -273,13 +396,12 @@ export const groqProvider: ProviderConfig = { arguments: tc.function.arguments, }, })), + ...(assistantReasoning ? { reasoning: assistantReasoning } : {}), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -290,10 +412,12 @@ export const groqProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -390,81 +514,7 @@ export const groqProvider: ProviderConfig = { } } catch (error) { logger.error('Error in Groq request:', { error }) - } - - if (request.stream) { - logger.info('Using streaming for final Groq response after tool processing') - - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: originalToolChoice || 'auto', - stream: true, - } - - const streamResponse = await groq.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) - - return streamingResult + throw error } const providerEndTime = Date.now() @@ -498,6 +548,9 @@ export const groqProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/groq/utils.ts b/apps/sim/providers/groq/utils.ts index 61e8563e962..cb97a689ea5 100644 --- a/apps/sim/providers/groq/utils.ts +++ b/apps/sim/providers/groq/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Groq streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Groq streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromGroqStream( groqStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(groqStream, 'Groq', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(groqStream, { + providerName: 'Groq', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 2f52c842461..7b0ee9697e8 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMockFns, resetEnvFlagsMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetApiKeyWithBYOK, mockExecuteRequest } = vi.hoisted(() => ({ mockGetApiKeyWithBYOK: vi.fn(), @@ -114,6 +115,106 @@ describe('executeProviderRequest — BYOK regression', () => { expect(segment?.cost?.total).toBeCloseTo(HOSTED_RATE_TOTAL_COST, 6) }) + /** + * Provider cost is now preferred over recomputation, because only the + * provider knows its cache tiers. Tool cost is the hazard in that branch: + * `executeProviderRequest` re-derives it from `toolResults`, so a provider + * that folded it into its own total must not have it counted twice. + */ + it('counts a provider-folded tool cost exactly once', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150 }, + cost: { + input: 0.0005, + output: 0.00125, + total: 0.00675, + toolCost: 0.005, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + }, + toolResults: [{ cost: { total: 0.005 } }], + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.toolCost).toBeCloseTo(0.005, 8) + expect(result.cost?.total).toBeCloseTo(0.00675, 8) + }) + + /** + * Gemini hands the same cost object to its response and its model segment. + * Adding tool cost by mutation would charge it to the segment too. + */ + it('does not leak tool cost into a segment sharing the provider cost object', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + envFlagsMockFns.getCostMultiplier.mockReturnValue(1) + const sharedCost = { + input: 0.0005, + output: 0.00125, + total: 0.00175, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + } + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150 }, + cost: sharedCost, + toolResults: [{ cost: { total: 0.004 } }], + timing: { + startTime: '2026-04-30T21:27:37.878Z', + endTime: '2026-04-30T21:27:38.000Z', + duration: 122, + timeSegments: [ + { + type: 'model', + name: 'claude-opus-4-6', + startTime: 1777584457878, + endTime: 1777584457940, + duration: 62, + cost: sharedCost, + }, + ], + }, + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.total).toBeCloseTo(0.00575, 8) + expect(result.timing?.timeSegments?.[0]?.cost?.total).toBeCloseTo(0.00175, 8) + }) + + it('keeps the provider cost rather than recomputing it from tokens', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + // Cache-tier pricing this layer cannot rebuild from `tokens` alone. + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150, cacheRead: 900, cacheWrite: 400 }, + cost: { + input: 0.0123, + output: 0.00125, + total: 0.01355, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + }, + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.input).toBeCloseTo(0.0123, 8) + expect(result.cost?.total).toBeCloseTo(0.01355, 8) + }) + it('preserves tool segment cost (BYOK does not suppress tool charges)', async () => { mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true }) const responseWithToolSegment: ProviderResponse = { @@ -215,3 +316,99 @@ describe('executeProviderRequest — BYOK regression', () => { expect(segments[0].cost.output).toBe(0) }) }) + +/** + * Streaming and non-streaming must charge identically. Providers price tokens + * inside the stream drain without knowing key provenance or the margin, so the + * shared policy is installed on the live output before the stream is returned. + */ +describe('executeProviderRequest — streaming cost policy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + }) + + afterEach(resetEnvFlagsMock) + + function makeStreamingExecution(initialCost?: Record) { + return { + stream: new ReadableStream(), + execution: { + success: true, + output: { + content: '', + model: 'claude-opus-4-6', + tokens: { input: 0, output: 0, total: 0 }, + ...(initialCost ? { cost: initialCost } : {}), + }, + logs: [], + }, + } + } + + it('applies the cost multiplier to cost the provider writes while streaming', async () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + const streaming = makeStreamingExecution() + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + stream: true, + }) + + streaming.execution.output.cost = { input: 1, output: 2, total: 3 } + + expect(streaming.execution.output.cost).toMatchObject({ input: 2, output: 4, total: 6 }) + }) + + it('does not charge for models Sim does not host', async () => { + const streaming = { + stream: new ReadableStream(), + execution: { + success: true, + output: { + content: '', + model: 'llama-3.3-70b-versatile', + tokens: { input: 0, output: 0, total: 0 }, + }, + logs: [], + }, + } + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('groq', { + model: 'llama-3.3-70b-versatile', + workspaceId: 'ws-1', + stream: true, + }) + + streaming.execution.output.cost = { input: 0.5, output: 1.5, total: 2 } + + expect(streaming.execution.output.cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) + + it('keeps tool cost from a settled stream that priced its tools before returning', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true }) + const streaming = makeStreamingExecution({ + input: 0.01, + output: 0.02, + total: 0.035, + toolCost: 0.005, + }) + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + stream: true, + }) + + expect(streaming.execution.output.cost).toMatchObject({ + input: 0, + output: 0, + total: 0.005, + toolCost: 0.005, + }) + }) +}) diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index b75860a1b11..9fc2c5593a2 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,8 +1,16 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { getApiKeyWithBYOK } from '@/lib/api-key/byok' -import { getCostMultiplier } from '@/lib/core/config/env-flags' import type { StreamingExecution } from '@/executor/types' +import { + applyModelCostPolicy, + applySegmentCostPolicy, + calculateBillableModelCost, + installStreamingCostPolicy, + type ModelCostPolicy, + resolveModelCostPolicy, + withoutToolCost, +} from '@/providers/cost-policy' import { attachLargeFileRemoteUrls, uploadLargeFilesToProvider, @@ -10,10 +18,9 @@ import { import { getProviderExecutor } from '@/providers/registry' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' import { - calculateCost, generateStructuredOutputInstructions, - shouldBillModelUsage, sumToolCosts, + supportsPromptCaching, supportsReasoningEffort, supportsTemperature, supportsThinking, @@ -48,6 +55,10 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest { sanitizedRequest.thinkingLevel = undefined } + if (model && !supportsPromptCaching(model)) { + sanitizedRequest.promptCaching = undefined + } + return sanitizedRequest } @@ -59,71 +70,26 @@ function isReadableStream(response: any): response is ReadableStream { return response instanceof ReadableStream } -const ZERO_COST = Object.freeze({ - input: 0, - output: 0, - total: 0, - pricing: Object.freeze({ input: 0, output: 0, updatedAt: new Date(0).toISOString() }), -}) - -const ZERO_SEGMENT_COST = Object.freeze({ input: 0, output: 0, total: 0 }) - -/** - * Zeroes per-segment model cost on already-populated time segments so that - * `calculateCostSummary` (which sums `span.children[].cost.total` from the - * trace-spans pipeline) does not re-introduce the gross hosted-rate cost - * after the provider response's block-level cost was correctly zeroed for - * BYOK. Tool-segment costs are intentionally left intact. - */ -function zeroModelSegmentCosts( - segments: { type?: string; cost?: { input?: number; output?: number; total?: number } }[] -): void { - for (const segment of segments) { - if (segment.type === 'model' && segment.cost) { - segment.cost = { ...ZERO_SEGMENT_COST } - } - } -} - /** - * Prevents streaming callbacks from writing non-zero model cost for BYOK users - * while preserving tool costs. The property is frozen via defineProperty because - * providers set cost inside streaming callbacks that fire after this function returns. + * Applies the shared model-cost policy to a streaming response. * - * Also zeroes any per-segment model cost already written by trace enrichers - * (which run synchronously before streaming begins for all current providers). + * The streaming and non-streaming paths must charge identically for the same + * model and tokens, but streaming providers write their cost from inside the + * stream drain — long after this function returns — so the policy is installed + * on the live output object rather than applied to a value. */ -function zeroCostForBYOK(response: StreamingExecution): void { - const output = response.execution?.output as - | (Record & { - providerTiming?: { - timeSegments?: Array<{ - type?: string - cost?: { input?: number; output?: number; total?: number } - }> - } - }) - | undefined +function applyStreamingCostPolicy(response: StreamingExecution, policy: ModelCostPolicy): void { + const output = response.execution?.output if (!output || typeof output !== 'object') { - logger.warn('zeroCostForBYOK: output not available at intercept time; cost may not be zeroed') + logger.warn('Streaming output unavailable at intercept time; cost policy not applied') return } - let toolCost = 0 - Object.defineProperty(output, 'cost', { - get: () => (toolCost > 0 ? { ...ZERO_COST, toolCost, total: toolCost } : ZERO_COST), - set: (value: Record) => { - if (value?.toolCost && typeof value.toolCost === 'number') { - toolCost = value.toolCost - } - }, - configurable: true, - enumerable: true, - }) + installStreamingCostPolicy(output, policy) const segments = output.providerTiming?.timeSegments if (Array.isArray(segments)) { - zeroModelSegmentCosts(segments) + applySegmentCostPolicy(segments, policy) } } @@ -201,9 +167,7 @@ export async function executeProviderRequest( if (isStreamingExecution(response)) { logger.info('Provider returned StreamingExecution', { isBYOK }) - if (isBYOK) { - zeroCostForBYOK(response) - } + applyStreamingCostPolicy(response, resolveModelCostPolicy(sanitizedRequest.model, isBYOK)) return response } @@ -212,54 +176,51 @@ export async function executeProviderRequest( return response } + const costPolicy = resolveModelCostPolicy(response.model, isBYOK) + if (response.tokens) { const { input: promptTokens = 0, output: completionTokens = 0 } = response.tokens - const useCachedInput = !!request.context && request.context.length > 0 - - const shouldBill = shouldBillModelUsage(response.model) && !isBYOK - if (shouldBill) { - const costMultiplier = getCostMultiplier() - response.cost = calculateCost( - response.model, - promptTokens, - completionTokens, - useCachedInput, - costMultiplier, - costMultiplier + + /** + * Any provider that reports cache buckets also prices itself, because only + * it knows the tiers involved — Anthropic's 5m vs 1h writes cannot be + * reconstructed from a single `cacheWrite` count. Its cost is therefore + * authoritative and only the policy is applied on top. The fallback prices + * providers that report no cache usage at all. + * + * Tool cost is stripped either way: it is re-derived from `toolResults` + * below and must not be counted twice. + */ + response.cost = response.cost + ? (applyModelCostPolicy(withoutToolCost(response.cost), costPolicy) as typeof response.cost) + : calculateBillableModelCost(response.model, promptTokens, completionTokens, { isBYOK }) + + if (!costPolicy.billable) { + logger.info( + isBYOK + ? `Not billing model usage for ${response.model} - workspace BYOK key used` + : `Not billing model usage for ${response.model} - user provided API key or not hosted model` ) - } else { - response.cost = { - input: 0, - output: 0, - total: 0, - pricing: { - input: 0, - output: 0, - updatedAt: new Date().toISOString(), - }, - } - if (isBYOK) { - logger.info(`Not billing model usage for ${response.model} - workspace BYOK key used`) - } else { - logger.info( - `Not billing model usage for ${response.model} - user provided API key or not hosted model` - ) - } } } - // Per-segment model costs are written by trace enrichers regardless of BYOK - // status. Zero them here so the trace-spans aggregator (which sums child - // span cost) does not re-introduce the gross hosted-rate cost after the - // block-level response.cost was already set to zero above. - if (isBYOK && response.timing?.timeSegments) { - zeroModelSegmentCosts(response.timing.timeSegments) + // Per-segment model costs are written by trace enrichers regardless of key + // provenance. Align them with the block-level decision so the displayed + // breakdown does not contradict the authoritative block cost. + if (response.timing?.timeSegments) { + applySegmentCostPolicy(response.timing.timeSegments, costPolicy) } const toolCost = sumToolCosts(response.toolResults) if (toolCost > 0 && response.cost) { - response.cost.toolCost = toolCost - response.cost.total += toolCost + // Replaced rather than mutated: a provider-supplied cost can be the same + // object it also handed to a time segment, and tool cost belongs only to + // the block total. + response.cost = { + ...response.cost, + toolCost, + total: response.cost.total + toolCost, + } } return response diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 0d02c00710a..a211c33a9bf 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -10,7 +12,10 @@ import { getProviderDefaultModel, getProviderModels, } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -202,26 +207,31 @@ export const kimiProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromKimiStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -306,7 +316,7 @@ export const kimiProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -342,6 +352,9 @@ export const kimiProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -361,31 +374,21 @@ export const kimiProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - const assistantReasoning = ( - currentResponse.choices[0]?.message as { reasoning_content?: string } | undefined - )?.reasoning_content - - currentMessages.push({ - role: 'assistant', - content: null, - ...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}), - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -396,10 +399,12 @@ export const kimiProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -502,12 +507,56 @@ export const kimiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'kimi' } ) + + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + } + finalPayload.tools = undefined + finalPayload.tool_choice = undefined + + const finalModelStartTime = Date.now() + currentResponse = await kimi.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'kimi' } + ) + iterationCount++ + } } } catch (error) { logger.error('Error in Kimi request:', { error }) @@ -515,23 +564,8 @@ export const kimiProvider: ProviderConfig = { } if (request.stream) { - logger.info('Using streaming for final Kimi response after tool processing') - - const streamingPayload: any = { - ...payload, - messages: currentMessages, - stream: true, - stream_options: { include_usage: true }, - } - streamingPayload.tools = undefined - streamingPayload.tool_choice = undefined - - const streamResponse = await kimi.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, @@ -553,8 +587,8 @@ export const kimiProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -564,28 +598,12 @@ export const kimiProvider: ProviderConfig = { } : undefined, isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -622,6 +640,10 @@ export const kimiProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/kimi/utils.ts b/apps/sim/providers/kimi/utils.ts index c52a0cd50ad..8e155fd6dd2 100644 --- a/apps/sim/providers/kimi/utils.ts +++ b/apps/sim/providers/kimi/utils.ts @@ -1,10 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +/** + * Creates an agent-events stream from a Kimi (Moonshot AI) streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. + */ export function createReadableStreamFromKimiStream( kimiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(kimiStream, 'Kimi', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(kimiStream, { + providerName: 'Kimi', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 04efada3fa3..50563bf21db 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -72,19 +72,30 @@ vi.mock('@/providers/utils', () => ({ })) import { litellmProvider } from '@/providers/litellm' +import type { AgentStreamEvent } from '@/providers/stream-events' import { ProviderError } from '@/providers/types' interface ChatOptions { content?: string | null toolCalls?: Array<{ id: string; function: { name: string; arguments: string } }> usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } + reasoning_content?: string } -function chat({ content = null, toolCalls, usage }: ChatOptions = {}) { +function chat({ + content = null, + toolCalls, + usage, + reasoning_content: reasoningContent, +}: ChatOptions = {}) { return { choices: [ { - message: { content, tool_calls: toolCalls }, + message: { + content, + tool_calls: toolCalls, + ...(reasoningContent !== undefined ? { reasoning_content: reasoningContent } : {}), + }, finish_reason: toolCalls ? 'tool_calls' : 'stop', }, ], @@ -107,6 +118,16 @@ function run(request: Record) { const firstPayload = () => mockCreate.mock.calls[0][0] const lastPayload = () => mockCreate.mock.calls.at(-1)![0] +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + describe('litellmProvider.executeRequest', () => { beforeEach(() => { vi.clearAllMocks() @@ -190,12 +211,13 @@ describe('litellmProvider.executeRequest', () => { expect(result.content).toBe('{"answer":1}') }) - it('defers response_format into the final streaming call while keeping tools', async () => { + it('uses one distinct non-streaming extraction call for deferred response_format', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) ) .mockResolvedValueOnce(chat({ content: 'mid' })) + .mockResolvedValueOnce(chat({ content: '{"answer":1}' })) const result = await run({ stream: true, @@ -204,12 +226,46 @@ describe('litellmProvider.executeRequest', () => { }) const final = lastPayload() - expect(final.stream).toBe(true) + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(final.stream).toBeUndefined() expect(final.response_format.type).toBe('json_schema') expect(final.tools).toBeDefined() expect(final.tool_choice).toBe('none') expect(final.parallel_tool_calls).toBe(false) expect(result.execution.isStreaming).toBe(true) + expect(result.execution.output.content).toBe('{"answer":1}') + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + await expect(readAgentEvents(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: '{"answer":1}', turn: 'final' }, + ]) + }) + + it('projects a normal settled tool-loop answer without regeneration', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'done' })) + + const result = await run({ stream: true, tools: [tool('known')] }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 10, output: 6, total: 16 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect(readAgentEvents(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: 'done', turn: 'final' }, + ]) }) it('threads assistant tool_calls and a named tool response, and reports toolCalls', async () => { @@ -238,7 +294,31 @@ describe('litellmProvider.executeRequest', () => { expect(result.content).toBe('done') }) - it('emits a stub tool response for an unanswered tool_call_id', async () => { + it('replays LiteLLM assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ + content: 'I will use the tool.', + toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }], + reasoning_content: 'Normalized reasoning.', + }) + ) + .mockResolvedValueOnce(chat({ content: 'done' })) + + await run({ tools: [tool('known')] }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Normalized reasoning.', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'known', arguments: '{}' } }], + }) + }) + + it('emits a structured response when the requested tool is unavailable', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'cX', function: { name: 'ghost', arguments: '{}' } }] }) @@ -254,7 +334,7 @@ describe('litellmProvider.executeRequest', () => { expect(toolMsg.content).toContain('not available') }) - it('executes a tool with empty arguments without failing', async () => { + it('rejects empty tool arguments without executing the tool', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'c1', function: { name: 'ping', arguments: '' } }] }) @@ -263,9 +343,9 @@ describe('litellmProvider.executeRequest', () => { await run({ tools: [tool('ping')] }) - expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect(mockExecuteTool).not.toHaveBeenCalled() const toolMsg = mockCreate.mock.calls[1][0].messages.find((m: any) => m.role === 'tool') - expect(toolMsg.content).not.toContain('"error":true') + expect(toolMsg.content).toContain('"error":true') }) it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { @@ -275,8 +355,10 @@ describe('litellmProvider.executeRequest', () => { await run({ tools: [tool('known')] }) - expect(mockCreate).toHaveBeenCalledTimes(1 + 20) + expect(mockCreate).toHaveBeenCalledTimes(1 + 20 + 1) expect(mockExecuteTool).toHaveBeenCalledTimes(20) + expect(lastPayload().tools).toBeUndefined() + expect(lastPayload().tool_choice).toBeUndefined() }) it('returns a streaming execution when streaming without active tools', async () => { diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 0f5fc2d3d2c..0135aac64b2 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { env } from '@/lib/core/config/env' @@ -8,7 +9,10 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromLiteLLMStream } from '@/providers/litellm/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -219,6 +223,7 @@ export const litellmProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { let cleanContent = content @@ -348,12 +353,25 @@ export const litellmProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = toolCall.function.arguments - ? JSON.parse(toolCall.function.arguments) - : {} + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -371,6 +389,9 @@ export const litellmProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -390,28 +411,21 @@ export const litellmProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - const respondedToolCallIds = new Set() - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -422,10 +436,12 @@ export const litellmProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -450,21 +466,6 @@ export const litellmProvider: ProviderConfig = { name: toolName, content: JSON.stringify(resultContent), }) - respondedToolCallIds.add(toolCall.id) - } - - for (const tc of toolCallsInResponse) { - if (respondedToolCallIds.has(tc.id)) continue - currentMessages.push({ - role: 'tool', - tool_call_id: tc.id, - name: tc.function.name, - content: JSON.stringify({ - error: true, - message: `Tool "${tc.function.name}" is not available`, - tool: tc.function.name, - }), - }) } const thisToolsTime = Date.now() - toolsStartTime @@ -537,91 +538,12 @@ export const litellmProvider: ProviderConfig = { ) } - if (request.stream) { - logger.info('Using streaming for final response after tool processing') - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingParams.response_format = responseFormatPayload - streamingParams.parallel_tool_calls = false - } - const streamResponse = await litellm.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } - - output.content = cleanContent - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) - - return streamingResult - } - + /** + * Deferred structured output is a distinct extraction step, not streaming + * regeneration. Some LiteLLM backends cannot combine schema output with tools. + */ if (deferResponseFormat && responseFormatPayload) { - logger.info('Applying deferred JSON schema response format after tool processing') + logger.info('Applying deferred JSON schema extraction after tool processing') const finalFormatStartTime = Date.now() const finalPayload: any = { @@ -651,7 +573,6 @@ export const litellmProvider: ProviderConfig = { if (formattedContent) { content = formattedContent.replace(/```json\n?|\n?```/g, '').trim() } - if (currentResponse.usage) { tokens.input += currentResponse.usage.prompt_tokens || 0 tokens.output += currentResponse.usage.completion_tokens || 0 @@ -664,6 +585,93 @@ export const litellmProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'litellm' } ) + } else if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await litellm.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'litellm' } + ) + } + + if (request.stream) { + logger.info('Projecting settled response after tool processing') + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) } const providerEndTime = Date.now() @@ -683,7 +691,7 @@ export const litellmProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -712,6 +720,10 @@ export const litellmProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/litellm/utils.ts b/apps/sim/providers/litellm/utils.ts index f779f95c703..bacbd3cacbb 100644 --- a/apps/sim/providers/litellm/utils.ts +++ b/apps/sim/providers/litellm/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a LiteLLM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a LiteLLM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromLiteLLMStream( litellmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(litellmStream, 'LiteLLM', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(litellmStream, { + providerName: 'LiteLLM', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index 96b0e0034ad..f4acdd81be0 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMetaStream } from '@/providers/meta/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -21,7 +25,6 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, - trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -148,6 +151,7 @@ export const metaProvider: ProviderConfig = { // backends reject a request that carries both `response_format` and active // `tools`/`tool_choice`. Defer the schema until after the tool loop completes. const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload } @@ -172,35 +176,37 @@ export const metaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromMetaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromMetaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] let currentResponse = await meta.chat.completions.create( payload, @@ -219,7 +225,6 @@ export const metaProvider: ProviderConfig = { const toolResults: Record[] = [] const currentMessages = [...formattedMessages] let iterationCount = 0 - let hasUsedForcedTool = false let modelTime = firstResponseTime let toolsTime = 0 @@ -233,23 +238,6 @@ export const metaProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - originalToolChoice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - try { while (iterationCount < MAX_TOOL_ITERATIONS) { if (currentResponse.choices[0]?.message?.content) { @@ -276,7 +264,7 @@ export const metaProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) // Every tool_call in the assistant message must be answered by a matching @@ -315,6 +303,9 @@ export const metaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -334,7 +325,7 @@ export const metaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -349,11 +340,9 @@ export const metaProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -364,10 +353,12 @@ export const metaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -401,48 +392,12 @@ export const metaProvider: ProviderConfig = { messages: currentMessages, } - if ( - typeof originalToolChoice === 'object' && - hasUsedForcedTool && - forcedTools.length > 0 - ) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - nextPayload.tool_choice = { - type: 'function', - function: { name: remainingTools[0] }, - } - logger.info(`Forcing next tool: ${remainingTools[0]}`) - } else { - nextPayload.tool_choice = 'auto' - logger.info('All forced tools have been used, switching to auto tool_choice') - } - } - const nextModelStartTime = Date.now() currentResponse = await meta.chat.completions.create( nextPayload, request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - nextPayload.tool_choice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - const nextModelEndTime = Date.now() const thisModelTime = nextModelEndTime - nextModelStartTime @@ -470,106 +425,66 @@ export const metaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'meta' } ) - } - } catch (error) { - logger.error('Error in Meta request:', { error }) - throw error - } - if (request.stream) { - logger.info('Using streaming for final Meta response after tool processing') - - // The tool loop is complete: this final pass only produces the textual answer. - // Meta rejects tool_choice: "none" (only "auto" is supported), so instead of - // forcing tool_choice we omit `tools` from this call entirely — with no tools - // declared, the model cannot emit a fresh tool call for the text-only adapter to drop. - const { tools: _omittedTools, ...streamingBasePayload } = payload - const streamingPayload: any = { - ...streamingBasePayload, - messages: currentMessages, - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - } - - const streamResponse = await meta.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const { tools: _omittedTools, ...finalPayload } = payload + finalPayload.messages = currentMessages + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await meta.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromMetaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'meta' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Meta request:', { error }) + throw error } // Tools were active, so `response_format` was withheld from the loop. Make one final // tool-free call to obtain the structured response now that the tool work is done. - // Meta rejects tool_choice: "none", so `tools` is dropped from this payload instead - // (see the streaming pass above for the same constraint). - if (deferResponseFormat && responseFormatPayload) { + // Meta rejects tool_choice: "none", so `tools` is dropped from this payload instead. + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -614,6 +529,52 @@ export const metaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -631,7 +592,7 @@ export const metaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -645,6 +606,10 @@ export const metaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/meta/utils.ts b/apps/sim/providers/meta/utils.ts index 1f72345ec5c..a8defd82d75 100644 --- a/apps/sim/providers/meta/utils.ts +++ b/apps/sim/providers/meta/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Meta Model API streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Meta Model API streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromMetaStream( metaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(metaStream, 'Meta', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(metaStream, { + providerName: 'Meta', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/mistral/index.test.ts b/apps/sim/providers/mistral/index.test.ts new file mode 100644 index 00000000000..ec5871ad47a --- /dev/null +++ b/apps/sim/providers/mistral/index.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderToolConfig } from '@/providers/types' + +const { mockCreate, mockExecuteTool } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: (messages: unknown) => messages, +})) +vi.mock('@/providers/mistral/utils', () => ({ + createReadableStreamFromMistralStream: vi.fn(), +})) +vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, + getProviderModels: vi.fn(() => []), + getProviderDefaultModel: vi.fn(() => 'mistral-large-latest'), +})) +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: vi.fn((tools) => ({ + tools, + toolChoice: 'auto', + forcedTools: [], + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +import { mistralProvider } from '@/providers/mistral' + +function makeTool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + +describe('mistralProvider.executeRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) + }) + + it('projects the settled tool-loop answer without a final streaming request', async () => { + mockCreate + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'done', tool_calls: undefined } }], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }) + + const result = await mistralProvider.executeRequest!({ + model: 'mistral-large-latest', + apiKey: 'key', + messages: [{ role: 'user', content: 'Use a tool' }], + stream: true, + tools: [makeTool('lookup')], + }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 6, output: 3, total: 9 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'done', turn: 'final' }]) + }) +}) diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 95c66422a72..ddf625a4596 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -7,7 +8,9 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -139,6 +142,11 @@ export const mistralProvider: ProviderConfig = { if (request.stream && (!tools || tools.length === 0)) { logger.info('Using streaming response for Mistral request') + /** + * Mistral reports stream usage on the terminal chunk on its own and + * rejects `stream_options` with HTTP 422 (`extra_forbidden`), so the + * opt-in every other OpenAI-compatible provider sends is omitted here. + */ const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, stream: true, @@ -155,6 +163,7 @@ export const mistralProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromMistralStream(streamResponse, (content, usage) => { output.content = content @@ -270,10 +279,25 @@ export const mistralProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -291,6 +315,9 @@ export const mistralProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -310,7 +337,7 @@ export const mistralProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', content: null, @@ -324,11 +351,9 @@ export const mistralProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -339,10 +364,12 @@ export const mistralProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -433,25 +460,55 @@ export const mistralProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'mistral' } ) + + if (currentResponse.choices[0]?.message?.tool_calls?.length) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await mistral.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'mistral' } + ) + } } if (request.stream) { - logger.info('Using streaming for final response after tool processing') + logger.info('Projecting settled response after tool processing') const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'auto', - stream: true, - } - const streamResponse = await mistral.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -460,7 +517,7 @@ export const mistralProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -471,7 +528,8 @@ export const mistralProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -480,31 +538,13 @@ export const mistralProvider: ProviderConfig = { count: toolCalls.length, } : undefined, - createStream: ({ output }) => - createReadableStreamFromMistralStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -524,7 +564,7 @@ export const mistralProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -538,6 +578,10 @@ export const mistralProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/mistral/utils.ts b/apps/sim/providers/mistral/utils.ts index 61893928918..b1d16c95304 100644 --- a/apps/sim/providers/mistral/utils.ts +++ b/apps/sim/providers/mistral/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Mistral streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Mistral streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromMistralStream( mistralStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(mistralStream, 'Mistral', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(mistralStream, { + providerName: 'Mistral', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 56eddcbeb6b..e8deb2a9311 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -5,10 +5,64 @@ import { describe, expect, it } from 'vitest' import { getBaseModelProviders, getHostedModels, + getModelsWithPromptCaching, + getPromptCachingMinimumTokens, + getThinkingStreamVisibility, isModelDeprecated, orderModelIdsByReleaseDate, PROVIDER_DEFINITIONS, } from '@/providers/models' +import { supportsPromptCaching } from '@/providers/utils' + +describe('Anthropic thinking stream visibility', () => { + it('classifies visible Claude thinking as summarized rather than raw', () => { + for (const providerId of ['anthropic', 'azure-anthropic'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + if (model.capabilities.thinking) { + expect(getThinkingStreamVisibility(model.id)).toBe('summary') + } + } + } + }) +}) + +describe('Meta thinking stream visibility', () => { + it('classifies private Muse reasoning as not streamed', () => { + expect(getThinkingStreamVisibility('muse-spark-1.1')).toBe('none') + }) +}) + +describe('prompt caching capability', () => { + const cachingModels = new Set(getModelsWithPromptCaching()) + + it('covers every Claude model on both Anthropic surfaces', () => { + for (const providerId of ['anthropic', 'azure-anthropic'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + expect(cachingModels.has(model.id)).toBe(true) + } + } + }) + + /** + * OpenAI and Gemini cache automatically with no caller control, so declaring + * the capability would put a switch in the UI that does nothing. + */ + it('excludes providers whose caching is automatic', () => { + for (const providerId of ['openai', 'google'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + expect(cachingModels.has(model.id)).toBe(false) + } + } + expect(supportsPromptCaching('gpt-5.5')).toBe(false) + }) + + it('reports the vendor minimum prefix, raised for Haiku', () => { + expect(getPromptCachingMinimumTokens('claude-sonnet-5')).toBe(1024) + expect(getPromptCachingMinimumTokens('claude-haiku-4-5')).toBe(2048) + expect(getPromptCachingMinimumTokens('azure-anthropic/claude-haiku-4-5')).toBe(2048) + expect(getPromptCachingMinimumTokens('gpt-5.5')).toBeNull() + }) +}) const DYNAMIC_PROVIDERS = new Set([ 'ollama', diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 23ab2236e38..aac911d4d5b 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -35,6 +35,9 @@ import { } from '@/components/icons' import type { ModelPricing, ProviderId } from '@/providers/types' +/** How a model's thinking appears on the agent-events stream. */ +export type ThinkingStreamVisibility = 'full' | 'summary' | 'none' + export interface ModelCapabilities { temperature?: { min: number @@ -51,9 +54,31 @@ export interface ModelCapabilities { verbosity?: { values: string[] } + /** + * Model accepts caller-placed prompt-cache breakpoints, so caching is a real + * opt-in with a cost tradeoff (writes carry a premium over base input). + * + * Absent for providers whose caching is automatic and free — OpenAI and + * Gemini implicit caching need no switch, and exposing one would imply a + * control that does not exist. + */ + promptCaching?: { + /** Prefixes shorter than this are silently not cached by the vendor. */ + minimumCacheableTokens: number + } thinking?: { levels: string[] default?: string + /** + * What this model's thinking looks like on the agent-events stream: + * `full` raw thinking deltas, `summary` summaries only, or `none` (the + * provider withholds thinking text entirely, e.g. the newest Claude + * models default to omitted thinking display). Anthropic-family models + * must set this explicitly since visibility varies per model generation — + * `bun run agent-stream-docs:check` enforces it. Other families fall back + * to the per-provider defaults in {@link getThinkingStreamVisibility}. + */ + streamed?: ThinkingStreamVisibility } deepResearch?: boolean /** Whether this model supports conversation memory. Defaults to true if omitted. */ @@ -741,6 +766,9 @@ export const PROVIDER_DEFINITIONS: Record = { color: '#D97757', capabilities: { toolUsageControl: true, + // Every Claude model accepts cache_control breakpoints; Haiku raises the + // minimum prefix and overrides this per-model. + promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ { @@ -757,6 +785,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -776,12 +805,34 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, releaseDate: '2026-06-30', recommended: true, }, + { + id: 'claude-opus-5', + pricing: { + input: 5.0, + cachedInput: 0.5, + output: 25.0, + updatedAt: '2026-07-24', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-07-24', + recommended: true, + }, { id: 'claude-opus-4-8', pricing: { @@ -796,11 +847,11 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, releaseDate: '2026-05-28', - recommended: true, }, { id: 'claude-opus-4-7', @@ -816,6 +867,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -836,6 +888,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -856,6 +909,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -876,6 +930,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -895,6 +950,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -915,6 +971,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -936,6 +993,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -955,6 +1013,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -973,9 +1032,11 @@ export const PROVIDER_DEFINITIONS: Record = { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, maxOutputTokens: 64000, + promptCaching: { minimumCacheableTokens: 2048 }, thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1326,6 +1387,8 @@ export const PROVIDER_DEFINITIONS: Record = { isReseller: true, capabilities: { toolUsageControl: true, + // Microsoft Foundry supports the same cache_control breakpoints. + promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ { @@ -1343,6 +1406,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -1363,6 +1427,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1383,6 +1448,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1402,6 +1468,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1420,9 +1487,11 @@ export const PROVIDER_DEFINITIONS: Record = { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, maxOutputTokens: 64000, + promptCaching: { minimumCacheableTokens: 2048 }, thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1885,7 +1954,7 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'deepseek', name: 'DeepSeek', description: "DeepSeek's chat models", - defaultModel: 'deepseek-chat', + defaultModel: 'deepseek-v4-flash', modelPatterns: [], icon: DeepseekIcon, color: '#4D6BFE', @@ -1901,7 +1970,16 @@ export const PROVIDER_DEFINITIONS: Record = { output: 0.87, updatedAt: '2026-06-16', }, - capabilities: {}, + capabilities: { + reasoningEffort: { + values: ['high', 'max'], + }, + thinking: { + levels: ['none', 'enabled'], + default: 'enabled', + }, + maxOutputTokens: 384000, + }, contextWindow: 1000000, releaseDate: '2026-04-24', }, @@ -1915,6 +1993,14 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['high', 'max'], + }, + thinking: { + levels: ['none', 'enabled'], + default: 'enabled', + }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2026-04-24', @@ -1929,6 +2015,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2024-12-26', @@ -1956,7 +2043,12 @@ export const PROVIDER_DEFINITIONS: Record = { output: 2.19, updatedAt: '2026-04-01', }, - capabilities: {}, + capabilities: { + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + }, contextWindow: 128000, releaseDate: '2025-01-20', sunset: { status: 'deprecated' }, @@ -1969,7 +2061,16 @@ export const PROVIDER_DEFINITIONS: Record = { output: 0.28, updatedAt: '2026-06-11', }, - capabilities: {}, + capabilities: { + reasoningEffort: { + values: ['high', 'max'], + }, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + maxOutputTokens: 384000, + }, contextWindow: 1000000, releaseDate: '2025-01-20', }, @@ -2299,6 +2400,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-08-05', @@ -2314,6 +2418,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-08-05', @@ -2328,6 +2435,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-10-29', @@ -2341,6 +2451,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 40960, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 131072, releaseDate: '2025-04-29', @@ -2355,6 +2469,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 32768, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 131072, releaseDate: '2026-04-21', @@ -4295,7 +4413,7 @@ export function supportsNativeStructuredOutputs(modelId: string): boolean { */ export function getThinkingCapability( modelId: string -): { levels: string[]; default?: string } | null { +): NonNullable | null { const normalizedModelId = modelId.toLowerCase() for (const provider of Object.values(PROVIDER_DEFINITIONS)) { @@ -4311,6 +4429,33 @@ export function getThinkingCapability( return null } +/** + * Get all models that accept caller-placed prompt-cache breakpoints. + * + * Reads merged provider+model capabilities because prompt caching is declared + * once per provider (every Claude model supports it) with per-model overrides + * only for the minimum prefix length. + */ +export function getModelsWithPromptCaching(): string[] { + const models: string[] = [] + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + for (const model of provider.models) { + if (model.capabilities.promptCaching ?? provider.capabilities?.promptCaching) { + models.push(model.id) + } + } + } + return models +} + +/** + * Minimum prefix length the model will cache, or `null` when the model does + * not support caller-placed breakpoints. + */ +export function getPromptCachingMinimumTokens(modelId: string): number | null { + return getModelCapabilities(modelId)?.promptCaching?.minimumCacheableTokens ?? null +} + /** * Get all models that support thinking capability */ @@ -4335,6 +4480,51 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null { return capability?.levels ?? null } +/** + * Per-provider defaults for thinking stream visibility, used when a model does + * not declare `capabilities.thinking.streamed` explicitly. Gemini and OpenAI + * stream summaries only; Bedrock and Meta do not expose reasoning text; + * OpenAI-compatible vendors that expose reasoning stream the raw chain of + * thought. + */ +const PROVIDER_THINKING_STREAM_DEFAULTS: Record = { + google: 'summary', + vertex: 'summary', + openai: 'summary', + 'azure-openai': 'summary', + bedrock: 'none', + meta: 'none', +} + +/** + * What a reasoning-capable model's thinking looks like on the agent-events + * stream (canvas terminal, opted-in deployed chat). Returns null for models + * with no thinking or reasoning-effort capability. Explicit per-model + * `capabilities.thinking.streamed` wins over the provider default; providers + * without a default stream the raw chain of thought when the vendor emits it. + */ +export function getThinkingStreamVisibility(modelId: string): ThinkingStreamVisibility | null { + const normalizedModelId = modelId.toLowerCase() + + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + for (const model of provider.models) { + const baseModelId = model.id.toLowerCase() + if (normalizedModelId !== baseModelId && !normalizedModelId.startsWith(`${baseModelId}-`)) { + continue + } + if (!model.capabilities.thinking && !model.capabilities.reasoningEffort) { + return null + } + return ( + model.capabilities.thinking?.streamed ?? + PROVIDER_THINKING_STREAM_DEFAULTS[provider.id] ?? + 'full' + ) + } + } + return null +} + /** * Get all models that support deep research capability */ diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index ffb7bf0fb25..17e5c631c74 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -1,12 +1,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createReadableStreamFromNvidiaStream } from '@/providers/nvidia/utils' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -128,6 +133,7 @@ export const nvidiaProvider: ProviderConfig = { } const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload } @@ -152,26 +158,31 @@ export const nvidiaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromNvidiaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -256,7 +267,7 @@ export const nvidiaProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -292,6 +303,9 @@ export const nvidiaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -311,26 +325,21 @@ export const nvidiaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -341,10 +350,12 @@ export const nvidiaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -447,99 +458,67 @@ export const nvidiaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'nvidia' } ) - } - } catch (error) { - logger.error('Error in NVIDIA NIM request:', { error }) - throw error - } - if (request.stream) { - logger.info('Using streaming for final NVIDIA NIM response after tool processing') - - const streamingPayload: any = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - streamingPayload.parallel_tool_calls = false - } - - const streamResponse = await nvidia.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + } + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.parallel_tool_calls = false + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await nvidia.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'nvidia' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in NVIDIA NIM request:', { error }) + throw error } - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -585,6 +564,52 @@ export const nvidiaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -602,7 +627,7 @@ export const nvidiaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -616,6 +641,10 @@ export const nvidiaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/nvidia/utils.ts b/apps/sim/providers/nvidia/utils.ts index ef9c37b3a50..45c0b526a4b 100644 --- a/apps/sim/providers/nvidia/utils.ts +++ b/apps/sim/providers/nvidia/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an NVIDIA NIM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an NVIDIA NIM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromNvidiaStream( nvidiaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(nvidiaStream, 'NVIDIA', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(nvidiaStream, { + providerName: 'NVIDIA', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index 1164e0be3e3..e199c2ebbf3 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -64,7 +64,11 @@ vi.mock('@/providers/ollama-cloud/utils', () => ({ onComplete: (content: string, usage: StreamUsage) => void ) => { streamOnComplete.current = onComplete - return 'OLLAMA_CLOUD_STREAM' + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, })) vi.mock('@/providers/utils', () => ({ @@ -79,10 +83,11 @@ vi.mock('@/providers/utils', () => ({ vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { ollamaCloudProvider } from '@/providers/ollama-cloud' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface StreamingResult { - stream: string + stream: string | ReadableStream execution: { output: { content: string @@ -97,10 +102,23 @@ interface StreamingResult { type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } } function completion( - opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {} + opts: { + content?: string | null + toolCalls?: ToolCallChunk[] + usage?: StreamUsage + reasoning?: string + } = {} ) { return { - choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }], + choices: [ + { + message: { + content: opts.content ?? null, + tool_calls: opts.toolCalls, + ...(opts.reasoning !== undefined ? { reasoning: opts.reasoning } : {}), + }, + }, + ], usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, } } @@ -116,6 +134,16 @@ function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): Provide } } +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + const baseRequest: ProviderRequest = { model: 'ollama-cloud/gpt-oss:120b', messages: [{ role: 'user', content: 'hi' }], @@ -234,6 +262,41 @@ describe('ollamaCloudProvider.executeRequest', () => { }) }) + it('replays Ollama Cloud assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'done' })) + + await ollamaCloudProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'mytool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without aborting the loop', async () => { mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' }) mockCreate @@ -321,7 +384,7 @@ describe('ollamaCloudProvider.executeRequest', () => { stream: true, })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_CLOUD_STREAM') + expect(result.stream).toBeInstanceOf(ReadableStream) expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) expect(result.execution.output.model).toBe('gpt-oss:120b') @@ -334,7 +397,7 @@ describe('ollamaCloudProvider.executeRequest', () => { expect(result.execution.output.tokens).toMatchObject({ input: 4, output: 6, total: 10 }) }) - it('streams the final response after a tool loop and removes tools/tool_choice', async () => { + it('projects the settled tool-loop answer without a regeneration call', async () => { mockCreate .mockResolvedValueOnce( completion({ @@ -343,7 +406,7 @@ describe('ollamaCloudProvider.executeRequest', () => { ], }) ) - .mockResolvedValueOnce(completion({ content: 'intermediate' })) + .mockResolvedValueOnce(completion({ content: 'final answer' })) const result = (await ollamaCloudProvider.executeRequest({ ...baseRequest, @@ -351,19 +414,14 @@ describe('ollamaCloudProvider.executeRequest', () => { tools: [makeTool('mytool')], })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_CLOUD_STREAM') + expect(mockCreate).toHaveBeenCalledTimes(2) expect(mockExecuteTool).toHaveBeenCalledTimes(1) - - const finalCall = mockCreate.mock.calls[2][0] - expect(finalCall.tools).toBeUndefined() - expect(finalCall.tool_choice).toBeUndefined() - - streamOnComplete.current?.('final answer', { - prompt_tokens: 2, - completion_tokens: 4, - total_tokens: 6, - }) expect(result.execution.output.content).toBe('final answer') + expect(result.execution.output.tokens).toEqual({ input: 10, output: 6, total: 16 }) expect(result.execution.output.toolCalls).toMatchObject({ count: 1 }) + expect(result.stream).toBeInstanceOf(ReadableStream) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) }) }) diff --git a/apps/sim/providers/ollama-cloud/utils.ts b/apps/sim/providers/ollama-cloud/utils.ts index 3ab364c66bb..d768b1d9134 100644 --- a/apps/sim/providers/ollama-cloud/utils.ts +++ b/apps/sim/providers/ollama-cloud/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an Ollama Cloud streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Ollama Cloud streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOllamaCloudStream( ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(ollamaStream, 'Ollama Cloud', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(ollamaStream, { + providerName: 'Ollama Cloud', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 9e10b7c436a..c3ba7a2a097 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -1,5 +1,6 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk, @@ -9,7 +10,11 @@ import type { CompletionUsage } from 'openai/resources/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' @@ -54,16 +59,16 @@ export interface OllamaCoreConfig { createClient: () => OpenAI createStream: ( stream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void - ) => ReadableStream + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void + ) => ReadableStream logger: Logger } /** * Shared execution logic for the Ollama-family providers, which speak the same * OpenAI-compatible Ollama API. Ollama ignores `tool_choice`, so tools are sent - * as `tool_choice: 'auto'` (forced tools degrade to auto) and the final post-tool - * call drops tools entirely rather than relying on `tool_choice: 'none'`. + * as `tool_choice: 'auto'` (forced tools degrade to auto). Tool-disabled calls + * drop tools entirely rather than relying on `tool_choice: 'none'`. */ export async function executeOllamaProviderRequest( request: ProviderRequest, @@ -181,6 +186,7 @@ export async function executeOllamaProviderRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => config.createStream(streamResponse, (content, usage) => { output.content = content @@ -286,10 +292,25 @@ export async function executeOllamaProviderRequest( const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -307,6 +328,9 @@ export async function executeOllamaProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -326,26 +350,21 @@ export async function executeOllamaProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -356,10 +375,12 @@ export async function executeOllamaProviderRequest( toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -438,90 +459,10 @@ export async function executeOllamaProviderRequest( ) } - if (request.stream) { - logger.info(`Using streaming for final ${providerLabel} response after tool processing`) - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const { tools: _tools, tool_choice: _toolChoice, ...streamPayload } = payload - - const finalMessages = request.responseFormat - ? applyJsonResponseFormat(streamPayload, currentMessages, request.responseFormat) - : currentMessages - - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...streamPayload, - messages: finalMessages, - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await ollama.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - createStream: ({ output }) => - config.createStream(streamResponse, (content, usage) => { - output.content = content - - if (content && request.responseFormat) { - output.content = content.replace(/```json\n?|\n?```/g, '').trim() - } - - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) - - return streamingResult - } - - // Deferred structured output: one final JSON-mode call now that tools have run. + /** + * Deferred structured output is a distinct extraction step, not streaming + * regeneration. Ollama cannot combine its JSON mode reliably with tool use. + */ if (request.responseFormat && hasActiveTools) { const finalPayload: any = { model: payload.model } if (payload.temperature !== undefined) finalPayload.temperature = payload.temperature @@ -563,6 +504,92 @@ export async function executeOllamaProviderRequest( finalResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: providerId } ) + } else if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { + /** + * The capped turn still requests tools, so make one tool-disabled call to + * synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await ollama.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: providerId } + ) + } + + if (request.stream) { + logger.info(`Projecting settled ${providerLabel} response after tool processing`) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) } const providerEndTime = Date.now() @@ -582,7 +609,7 @@ export async function executeOllamaProviderRequest( modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -611,6 +638,10 @@ export async function executeOllamaProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index 00ec2b43c32..a0c358d7559 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -51,7 +51,11 @@ vi.mock('@/providers/ollama/utils', () => ({ onComplete: (content: string, usage: StreamUsage) => void ) => { streamOnComplete.current = onComplete - return 'OLLAMA_STREAM' + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, })) vi.mock('@/providers/utils', () => ({ @@ -69,10 +73,11 @@ vi.mock('@/stores/providers', () => ({ })) import { ollamaProvider } from '@/providers/ollama' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface StreamingResult { - stream: string + stream: string | ReadableStream execution: { output: { content: string @@ -85,10 +90,23 @@ interface StreamingResult { type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } } function completion( - opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {} + opts: { + content?: string | null + toolCalls?: ToolCallChunk[] + usage?: StreamUsage + reasoning?: string + } = {} ) { return { - choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }], + choices: [ + { + message: { + content: opts.content ?? null, + tool_calls: opts.toolCalls, + ...(opts.reasoning !== undefined ? { reasoning: opts.reasoning } : {}), + }, + }, + ], usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, } } @@ -104,6 +122,16 @@ function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): Provide } } +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + const baseRequest: ProviderRequest = { model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }], @@ -230,6 +258,41 @@ describe('ollamaProvider.executeRequest', () => { }) }) + it('replays Ollama assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'done' })) + + await ollamaProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'mytool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without aborting the loop', async () => { mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' }) mockCreate @@ -321,7 +384,7 @@ describe('ollamaProvider.executeRequest', () => { stream: true, })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_STREAM') + expect(result.stream).toBeInstanceOf(ReadableStream) expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) streamOnComplete.current?.('streamed text', { @@ -348,7 +411,7 @@ describe('ollamaProvider.executeRequest', () => { expect(result.execution.output.content).toBe('{"a":1}') }) - it('streams the final response after a tool loop, carrying tool calls', async () => { + it('projects the settled tool-loop answer without a regeneration call', async () => { mockCreate .mockResolvedValueOnce( completion({ @@ -357,7 +420,7 @@ describe('ollamaProvider.executeRequest', () => { ], }) ) - .mockResolvedValueOnce(completion({ content: 'intermediate' })) + .mockResolvedValueOnce(completion({ content: 'final answer' })) const result = (await ollamaProvider.executeRequest({ ...baseRequest, @@ -365,19 +428,56 @@ describe('ollamaProvider.executeRequest', () => { tools: [makeTool('mytool')], })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_STREAM') + expect(mockCreate).toHaveBeenCalledTimes(2) expect(mockExecuteTool).toHaveBeenCalledTimes(1) - - const finalCall = mockCreate.mock.calls[2][0] - expect(finalCall.tools).toBeUndefined() - expect(finalCall.tool_choice).toBeUndefined() - - streamOnComplete.current?.('final answer', { - prompt_tokens: 2, - completion_tokens: 4, - total_tokens: 6, - }) expect(result.execution.output.content).toBe('final answer') + expect(result.execution.output.tokens).toEqual({ input: 10, output: 6, total: 16 }) expect(result.execution.output.toolCalls).toMatchObject({ count: 1 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + expect(result.stream).toBeInstanceOf(ReadableStream) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) + }) + + it('retains one distinct structured extraction call after a streamed tool loop', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'unstructured answer' })) + .mockResolvedValueOnce(completion({ content: '```json\n{"a":1}\n```' })) + + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [makeTool('mytool')], + responseFormat: { name: 'r', schema: { type: 'object' } }, + })) as unknown as StreamingResult + + expect(mockCreate).toHaveBeenCalledTimes(3) + const extractionCall = mockCreate.mock.calls[2][0] + expect(extractionCall.stream).toBeUndefined() + expect(extractionCall.response_format).toEqual({ type: 'json_object' }) + expect(extractionCall.tools).toBeUndefined() + expect(result.execution.output.content).toBe('{"a":1}') + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: '{"a":1}', turn: 'final' }]) }) }) diff --git a/apps/sim/providers/ollama/utils.ts b/apps/sim/providers/ollama/utils.ts index 45ea5d9957f..71e7890f7a5 100644 --- a/apps/sim/providers/ollama/utils.ts +++ b/apps/sim/providers/ollama/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an Ollama streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Ollama streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOllamaStream( ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(ollamaStream, 'Ollama', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(ollamaStream, { + providerName: 'Ollama', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/openai-compat/assistant-history.ts b/apps/sim/providers/openai-compat/assistant-history.ts new file mode 100644 index 00000000000..24b4b1d002b --- /dev/null +++ b/apps/sim/providers/openai-compat/assistant-history.ts @@ -0,0 +1,74 @@ +export type OpenAICompatReasoningField = 'reasoning' | 'reasoning_content' + +interface OpenAICompatResponseAssistantMessage { + content: string | null +} + +interface OpenAICompatResponseAssistantReasoning { + reasoning?: string + reasoning_content?: string +} + +interface OpenAICompatToolCall { + id: string + function: { + name: string + arguments: string + } +} + +export interface OpenAICompatAssistantHistoryMessage { + [key: string]: unknown + role: 'assistant' + content: string | null + tool_calls: Array<{ + id: string + type: 'function' + function: { + name: string + arguments: string + } + }> + reasoning?: string + reasoning_content?: string +} + +interface CreateOpenAICompatAssistantHistoryOptions { + message: OpenAICompatResponseAssistantMessage + toolCalls: readonly OpenAICompatToolCall[] + reasoningFields: readonly OpenAICompatReasoningField[] +} + +/** + * Replays an OpenAI-compatible assistant tool turn without replacing provider + * content or inventing reasoning fields the provider did not emit. + */ +export function createOpenAICompatAssistantHistory({ + message, + toolCalls, + reasoningFields, +}: CreateOpenAICompatAssistantHistoryOptions): OpenAICompatAssistantHistoryMessage { + const reasoningMessage = message as OpenAICompatResponseAssistantMessage & + OpenAICompatResponseAssistantReasoning + const history: OpenAICompatAssistantHistoryMessage = { + role: 'assistant', + content: message.content, + tool_calls: toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: 'function', + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }, + })), + } + + for (const field of reasoningFields) { + const value = reasoningMessage[field] + if (typeof value === 'string') { + history[field] = value + } + } + + return history +} diff --git a/apps/sim/providers/openai-compat/stream-events.test.ts b/apps/sim/providers/openai-compat/stream-events.test.ts new file mode 100644 index 00000000000..74fb5305d2e --- /dev/null +++ b/apps/sim/providers/openai-compat/stream-events.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + openaiCompatReasoningAndTextChunks, + openaiCompatTextOnlyChunks, + openaiCompatToolCallStartChunks, +} from '@/providers/__fixtures__/openai-compat' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createOpenAICompatibleAgentEventStream', () => { + it('emits thinking_delta from reasoning_content then text_delta', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatReasoningAndTextChunks as any + })(), + { providerName: 'DeepSeek', onComplete } + ) + + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should compute carefully. ', + 'Answer is 4.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { type: 'text_delta', text: '2+2=', turn: 'final' }, + { type: 'text_delta', text: '4', turn: 'final' }, + ]) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: '2+2=4', + thinking: 'I should compute carefully. Answer is 4.', + usage: { prompt_tokens: 10, completion_tokens: 8 }, + }) + }) + + it('stays text-only when no reasoning fields are present', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'Groq' } + ) + const events = await collectEvents(stream) + expect(events.every((e) => e.type === 'text_delta')).toBe(true) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + }) + + it('emits tool_call_start when enabled and id+name are known', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatToolCallStartChunks as any + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"https://x"}' } }], + }, + }, + ], + } + })(), + { providerName: 'Groq', emitToolCallStarts: true, onComplete } + ) + const events = await collectEvents(stream) + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 'call_abc', + name: 'http_request', + }) + // Only once even if later deltas omit id + expect(events.filter((e) => e.type === 'tool_call_start')).toHaveLength(1) + expect(onComplete.mock.calls[0][0].toolCalls).toEqual([ + { + id: 'call_abc', + type: 'function', + function: { name: 'http_request', arguments: '{"url":"https://x"}' }, + }, + ]) + }) + + it('keeps a synthesized tool id stable when the vendor id arrives after start', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + // Name first (no id) → start emits with a synthesized id. + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { name: 'lookup', arguments: '{"q"' } }], + }, + }, + ], + } as any + // Vendor id arrives late — must not rename the started call. + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, id: 'call_real', function: { arguments: ':1}' } }], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } as any + })(), + { providerName: 'Loose', emitToolCallStarts: true, onComplete } + ) + + const events = await collectEvents(stream) + const start = events.find((e) => e.type === 'tool_call_start') + expect(start).toBeDefined() + expect(start!.id).not.toBe('call_real') + + // The assembled call keeps the same id as the emitted start. + const assembled = onComplete.mock.calls[0][0].toolCalls + expect(assembled).toHaveLength(1) + expect(assembled[0].id).toBe(start!.id) + expect(assembled[0].function).toEqual({ name: 'lookup', arguments: '{"q":1}' }) + }) + + it('always enqueues text deltas live and assembles content for onComplete', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'DeepSeek', onComplete } + ) + const events = await collectEvents(stream) + expect( + events + .filter((e) => e.type === 'text_delta') + .map((e) => e.text) + .join('') + ).toBe('Hello world') + expect(onComplete.mock.calls[0][0].content).toBe('Hello world') + }) + + it('surfaces documented in-band provider errors', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield { + error: { message: 'Upstream provider failed' }, + choices: [], + } as any + })(), + { providerName: 'OpenRouter' } + ) + + await expect(collectEvents(stream)).rejects.toThrow('Upstream provider failed') + }) +}) diff --git a/apps/sim/providers/openai-compat/stream-events.ts b/apps/sim/providers/openai-compat/stream-events.ts new file mode 100644 index 00000000000..9d5ef438279 --- /dev/null +++ b/apps/sim/providers/openai-compat/stream-events.ts @@ -0,0 +1,286 @@ +/** + * OpenAI Chat Completions → agent-events-v1. + * + * Capability-honest: emits `thinking_delta` only when the vendor streams a + * reasoning field on the delta (`reasoning_content`, `reasoning`, etc.). + * Non-reasoning models stay text-only. Tool_call starts emit when a name is + * known (ids are synthesized when the vendor omits them, so the assembled + * request stays self-consistent). Tool-call argument deltas are accumulated + * for tool-loop history. + */ + +import { createLogger } from '@sim/logger' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { + getOpenRouterReasoningDetailText, + type OpenRouterReasoningDetail, +} from '@/providers/openrouter/reasoning' +import type { AgentStreamEvent, TextDeltaTurn } from '@/providers/stream-events' +import { ensureToolCallId } from '@/providers/tool-call-id' + +export interface OpenAICompatAssembledToolCall { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +export interface OpenAICompatStreamComplete { + content: string + thinking: string + /** DeepSeek-style CoT field accumulated from deltas. */ + reasoning_content?: string + /** Groq-style reasoning field accumulated from deltas. */ + reasoning?: string + /** OpenRouter reasoning blocks accumulated without reordering or normalization. */ + reasoning_details?: OpenRouterReasoningDetail[] + usage: CompletionUsage + /** Assembled when emitToolCallStarts is true (id + name + args). */ + toolCalls?: OpenAICompatAssembledToolCall[] + /** Last finish_reason observed on the stream (e.g. `tool_calls`, `stop`, `length`). */ + finishReason?: string +} + +export interface CreateOpenAICompatibleAgentEventStreamOptions { + providerName: string + /** Tag for answer text (default `final`). */ + turn?: TextDeltaTurn + /** Emit tool_call_start from delta.tool_calls when id+name known. Default false for no-tools path. */ + emitToolCallStarts?: boolean + onComplete?: (result: OpenAICompatStreamComplete) => void +} + +/** + * Chat Completions delta plus the vendor reasoning extensions the OpenAI SDK + * types don't carry (DeepSeek `reasoning_content`; Groq/OpenRouter `reasoning` + * as a string or `{ text }` object). All extensions are optional, so SDK + * deltas assign to this without casts. + */ +type CompatChunkDelta = ChatCompletionChunk.Choice.Delta & { + reasoning_content?: string + reasoning?: string | { text?: string } +} + +export type OpenRouterChatCompletionChunk = ChatCompletionChunk & { + choices: Array< + ChatCompletionChunk.Choice & { + delta: CompatChunkDelta & { + reasoning_details?: OpenRouterReasoningDetail[] + } + } + > +} + +interface CompatStreamExtension { + error?: string | { message?: string } + x_groq?: { + usage?: CompletionUsage + error?: string | { message?: string } + } +} + +function extractDeltaReasoning(delta: CompatChunkDelta | undefined): { + text: string + reasoning_content?: string + reasoning?: string +} { + if (!delta) return { text: '' } + + if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) { + return { text: delta.reasoning_content, reasoning_content: delta.reasoning_content } + } + if (typeof delta.reasoning === 'string' && delta.reasoning) { + return { text: delta.reasoning, reasoning: delta.reasoning } + } + if ( + delta.reasoning && + typeof delta.reasoning === 'object' && + typeof delta.reasoning.text === 'string' + ) { + const text = delta.reasoning.text + return { text, reasoning: text } + } + return { text: '' } +} + +/** + * Converts an OpenAI-compatible chat.completions stream into an in-process + * {@link AgentStreamEvent} object stream. + */ +export function createOpenAICompatibleAgentEventStream( + stream: AsyncIterable, + options: CreateOpenAICompatibleAgentEventStreamOptions +): ReadableStream { + const { providerName, turn = 'final', emitToolCallStarts = false, onComplete } = options + const streamLogger = createLogger(`${providerName}Utils`) + let cancelled = false + let streamIterator: AsyncIterator | undefined + + return new ReadableStream({ + async start(controller) { + let fullContent = '' + let fullThinking = '' + let reasoningContent = '' + let reasoning = '' + const reasoningDetails: OpenRouterReasoningDetail[] = [] + let promptTokens = 0 + let completionTokens = 0 + let totalTokens = 0 + let finishReason: string | undefined + const seenToolIds = new Set() + const toolBuffers = new Map< + number, + { id?: string; name?: string; args: string; started: boolean } + >() + + try { + streamIterator = stream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const chunk = next.value + const extension = chunk as ChatCompletionChunk & CompatStreamExtension + if (extension.error) { + const message = + typeof extension.error === 'string' ? extension.error : extension.error.message + throw new Error(message || `${providerName} stream error`) + } + if (extension.x_groq?.error) { + const message = + typeof extension.x_groq.error === 'string' + ? extension.x_groq.error + : extension.x_groq.error.message + throw new Error(message || 'Groq stream error') + } + /** + * Groq puts stream usage under `x_groq.usage` on the final chunk + * instead of the OpenAI `usage` field; accept either shape. + */ + const usage = chunk.usage ?? extension.x_groq?.usage + if (usage) { + promptTokens = usage.prompt_tokens ?? 0 + completionTokens = usage.completion_tokens ?? 0 + totalTokens = usage.total_tokens ?? 0 + } + + const choice = chunk.choices?.[0] + if (choice?.finish_reason) { + finishReason = choice.finish_reason + } + const delta: CompatChunkDelta | undefined = choice?.delta + const openRouterDelta = (chunk as OpenRouterChatCompletionChunk).choices?.[0]?.delta + const chunkReasoningDetails = Array.isArray(openRouterDelta?.reasoning_details) + ? openRouterDelta.reasoning_details + : [] + let structuredThinking = '' + for (const detail of chunkReasoningDetails) { + reasoningDetails.push(detail) + const text = getOpenRouterReasoningDetailText(detail) + if (text) { + structuredThinking += text + controller.enqueue({ type: 'thinking_delta', text }) + } + } + fullThinking += structuredThinking + + const extracted = extractDeltaReasoning(delta) + if (extracted.text) { + if (extracted.reasoning_content) reasoningContent += extracted.reasoning_content + if (extracted.reasoning) reasoning += extracted.reasoning + if (!structuredThinking) { + fullThinking += extracted.text + controller.enqueue({ type: 'thinking_delta', text: extracted.text }) + } + } + + const content = typeof delta?.content === 'string' ? delta.content : '' + if (content) { + fullContent += content + controller.enqueue({ type: 'text_delta', text: content, turn }) + } + + if (emitToolCallStarts && Array.isArray(delta?.tool_calls)) { + for (const tc of delta.tool_calls) { + // Loose vendors omit index on single-call streams; default to slot 0. + const index = typeof tc.index === 'number' ? tc.index : 0 + const buf = toolBuffers.get(index) ?? { + id: undefined, + name: undefined, + args: '', + started: false, + } + /** + * Never rename a call whose start already went out — a vendor id + * arriving after a synthesized one would orphan the emitted + * tool_call_start (its end would carry a different id). + */ + if (tc.id && !buf.started) buf.id = tc.id + if (tc.function?.name) buf.name = tc.function.name + /** + * Some compat vendors stream tool calls without ids. Synthesize + * an execution-local id as soon as the name is known so the call + * is not dropped — the assembled request only needs ids that are + * self-consistent between `tool_calls` and `tool` messages. + */ + if (!buf.id && buf.name) { + buf.id = ensureToolCallId(undefined, providerName.toLowerCase()) + } + if (typeof tc.function?.arguments === 'string') { + buf.args += tc.function.arguments + } + toolBuffers.set(index, buf) + + if (buf.id && buf.name && !buf.started && !seenToolIds.has(buf.id)) { + buf.started = true + seenToolIds.add(buf.id) + controller.enqueue({ type: 'tool_call_start', id: buf.id, name: buf.name }) + } + } + } + } + + if (cancelled) return + if (onComplete) { + if (promptTokens === 0 && completionTokens === 0) { + streamLogger.warn(`${providerName} stream completed without usage data`) + } + const toolCalls: OpenAICompatAssembledToolCall[] = [] + if (emitToolCallStarts) { + for (const [, buf] of [...toolBuffers.entries()].sort(([a], [b]) => a - b)) { + if (!buf.id || !buf.name) continue + toolCalls.push({ + id: buf.id, + type: 'function', + function: { name: buf.name, arguments: buf.args || '{}' }, + }) + } + } + onComplete({ + content: fullContent, + thinking: fullThinking, + ...(reasoningContent ? { reasoning_content: reasoningContent } : {}), + ...(reasoning ? { reasoning } : {}), + ...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}), + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: totalTokens || promptTokens + completionTokens, + }, + ...(toolCalls.length > 0 ? { toolCalls } : {}), + ...(finishReason ? { finishReason } : {}), + }) + } + + controller.close() + } catch (error) { + if (!cancelled) { + controller.error(error) + } + } + }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, + }) +} diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..b08b4050cba --- /dev/null +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -0,0 +1,770 @@ +/** + * @vitest-environment node + */ + +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { openaiCompatToolCallStartChunks } from '@/providers/__fixtures__/openai-compat' +import type { OpenRouterChatCompletionChunk } from '@/providers/openai-compat/stream-events' +import { + createOpenAICompatStreamingToolLoopStream, + type OpenAICompatCreateCompletion, +} from '@/providers/openai-compat/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderToolConfig, TimeSegment } from '@/providers/types' + +const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), + mockPrepareToolExecution: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: mockPrepareToolExecution, + calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), + sumToolCosts: () => 0, + /** Minimal faithful tracking: marks the forced tool used when the model called it. */ + trackForcedToolUsage: ( + toolCalls: Array<{ function?: { name?: string } }>, + toolChoice: unknown, + _logger: unknown, + _provider: unknown, + _forcedTools: string[], + usedForcedTools: string[] + ) => { + const forcedName = + toolChoice && typeof toolChoice === 'object' + ? (toolChoice as { function?: { name?: string } }).function?.name + : undefined + const usedNow = Boolean( + forcedName && toolCalls.some((toolCall) => toolCall.function?.name === forcedName) + ) + return { + hasUsedForcedTool: usedNow || usedForcedTools.length > 0, + usedForcedTools: usedNow && forcedName ? [...usedForcedTools, forcedName] : usedForcedTools, + } + }, +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +function toolThenAnswerChunks(toolName: string, args: string, answer: string) { + return [ + { + choices: [ + { + delta: { + reasoning_content: 'I should call the tool. ', + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: toolName, arguments: '' }, + }, + ], + }, + }, + ], + }, + { + choices: [ + { + finish_reason: 'tool_calls', + delta: { + tool_calls: [{ index: 0, function: { arguments: args } }], + }, + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + }, + // Second turn (final answer) — yielded by a separate createStream call + ] as const +} + +function openRouterChunk( + delta: OpenRouterChatCompletionChunk['choices'][number]['delta'], + finishReason: ChatCompletionChunk.Choice['finish_reason'] = null, + usage?: CompletionUsage +): OpenRouterChatCompletionChunk { + return { + id: 'chunk', + object: 'chat.completion.chunk', + created: 0, + model: 'openrouter/test-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + usage, + } +} + +describe('createOpenAICompatStreamingToolLoopStream', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + beforeEach(() => { + mockExecuteTool.mockReset() + mockPrepareToolExecution.mockReset() + logger.info.mockReset() + mockPrepareToolExecution.mockImplementation((_tool: unknown, args: unknown) => ({ + toolParams: args, + executionParams: args, + })) + mockExecuteTool.mockResolvedValue({ + success: true, + output: { answer: 42 }, + }) + }) + + it('keeps reasoning_content on assistant history when preserveAssistantReasoning is true', async () => { + const messageHistory: unknown[][] = [] + let call = 0 + + const createStream = vi.fn(async (params: { messages: unknown[] }) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [ + { + delta: { content: 'done', reasoning_content: 'final thought' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const timeSegments: TimeSegment[] = [] + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + thinkingLevel: 'enabled', + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments, + preserveAssistantReasoning: true, + onComplete: () => {}, + }) + + await collectEvents(stream) + + expect(createStream).toHaveBeenCalledTimes(2) + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (m) => m.role === 'assistant' && Array.isArray(m.tool_calls) + ) + expect(assistantWithTools).toMatchObject({ + role: 'assistant', + reasoning_content: 'I should call the tool. ', + }) + const modelSegments = timeSegments.filter((segment) => segment.type === 'model') + expect(modelSegments[0]).toMatchObject({ + thinkingContent: 'I should call the tool. ', + finishReason: 'tool_calls', + tokens: { input: 5, output: 3, total: 8 }, + provider: 'deepseek', + toolCalls: [{ id: 'call_1', name: 'lookup', arguments: {} }], + }) + expect(modelSegments[1]).toMatchObject({ + assistantContent: 'done', + thinkingContent: 'final thought', + finishReason: 'stop', + tokens: { input: 8, output: 2, total: 10 }, + provider: 'deepseek', + }) + }) + + it('replays interleaved OpenRouter reasoning_details unchanged and in order', async () => { + const messageHistory: unknown[][] = [] + const lookupTool: ProviderToolConfig = { + id: 'lookup', + name: 'lookup', + description: 'Looks up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } + const reasoningDetails = [ + { + type: 'reasoning.text', + text: 'Inspect the request. ', + signature: null, + id: 'reasoning-1', + format: 'anthropic-claude-v1', + index: 0, + }, + { + type: 'reasoning.encrypted', + data: 'opaque-data', + id: 'reasoning-2', + format: 'anthropic-claude-v1', + index: 1, + }, + { + type: 'reasoning.summary', + summary: 'Use the lookup result.', + id: 'reasoning-3', + format: 'anthropic-claude-v1', + index: 2, + }, + ] as const + let call = 0 + + const createStream: OpenAICompatCreateCompletion = vi.fn(async (params) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield openRouterChunk({ reasoning_details: [reasoningDetails[0]] }) + yield openRouterChunk({ + content: 'I will look that up.', + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '' }, + }, + ], + }) + yield openRouterChunk({ reasoning_details: [reasoningDetails[1]] }) + yield openRouterChunk( + { + reasoning_details: [reasoningDetails[2]], + tool_calls: [{ index: 0, function: { arguments: '{}' } }], + }, + 'tool_calls', + { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } + ) + })() + } + return (async function* () { + yield openRouterChunk({ content: 'done' }, 'stop', { + prompt_tokens: 8, + completion_tokens: 2, + total_tokens: 10, + }) + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenRouter', + request: { + model: 'openrouter/anthropic/claude-sonnet-4', + apiKey: 'k', + messages: [], + tools: [lookupTool], + }, + basePayload: { model: 'anthropic/claude-sonnet-4' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(events.filter((event) => event.type === 'thinking_delta')).toEqual([ + { type: 'thinking_delta', text: 'Inspect the request. ' }, + { type: 'thinking_delta', text: 'Use the lookup result.' }, + ]) + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (message) => message.role === 'assistant' && Array.isArray(message.tool_calls) + ) + expect(assistantWithTools).toEqual({ + role: 'assistant', + content: 'I will look that up.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + reasoning_details: reasoningDetails, + }) + }) + + it('omits reasoning_content when preserveAssistantReasoning is false', async () => { + const messageHistory: unknown[][] = [] + let call = 0 + + const createStream = vi.fn(async (params: { messages: unknown[] }) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Groq', + request: { + model: 'groq/openai/gpt-oss-120b', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { model: 'openai/gpt-oss-120b' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments: [], + preserveAssistantReasoning: false, + onComplete: () => {}, + }) + + await collectEvents(stream) + + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (m) => m.role === 'assistant' && Array.isArray(m.tool_calls) + ) + expect(assistantWithTools).toBeDefined() + expect(assistantWithTools?.reasoning_content).toBeUndefined() + }) + + it('rotates forced tool_choice to auto after the forced tool is used', async () => { + const toolChoices: unknown[] = [] + let call = 0 + + const createStream = vi.fn(async (params: { tool_choice?: unknown }) => { + toolChoices.push(params.tool_choice) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'final answer' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { + model: 'deepseek-chat', + tool_choice: { type: 'function', function: { name: 'lookup' } }, + }, + messages: [{ role: 'user', content: 'force lookup then answer' }], + createStream: createStream as any, + logger, + timeSegments: [], + forcedTools: ['lookup'], + onComplete: () => {}, + }) + ) + + expect(createStream).toHaveBeenCalledTimes(2) + expect(toolChoices[0]).toEqual({ type: 'function', function: { name: 'lookup' } }) + expect(toolChoices[1]).toBe('auto') + // Text streams live as `pending`; turn_end classifies each turn. + expect( + events.some( + (e) => e.type === 'text_delta' && e.text === 'final answer' && e.turn === 'pending' + ) + ).toBe(true) + expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ + 'intermediate', + 'final', + ]) + expect(logger.info).toHaveBeenCalledWith( + 'All forced tools have been used, switching to auto tool_choice' + ) + }) + + it('fails the call without executing when tool argument JSON is malformed', async () => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{"query": "unterminated', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'recovered' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + // Tool must not run with defaulted {} args; the call fails and the model + // gets the error result on the next turn. + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 'call_1', + name: 'lookup', + status: 'error', + }) + expect(createStream).toHaveBeenCalledTimes(2) + }) + + it.each(['null', '[]', '"text"', '0', 'false'])( + 'does not execute tools with non-object arguments: %s', + async (argumentsJson) => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', argumentsJson, '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'recovered' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(createStream).toHaveBeenCalledTimes(2) + } + ) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + const createStream = vi.fn(async () => { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + }) + mockExecuteTool.mockRejectedValueOnce(new DOMException('cancelled', 'AbortError')) + + const onComplete = vi.fn() + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(createStream).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 5, output: 3, total: 8 } }) + ) + }) + + it.each([ + [false, 'false'], + [0, '0'], + ['', '""'], + [null, 'null'], + ] as const)('replays successful falsy tool output %j', async (output, expectedContent) => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + mockExecuteTool.mockResolvedValueOnce({ success: true, output }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + const secondPayload = createStream.mock.calls[1][0] as { + messages: Array<{ role: string; content?: string }> + } + expect(secondPayload.messages).toContainEqual( + expect.objectContaining({ role: 'tool', content: expectedContent }) + ) + }) + + it('streams thinking live and assembles tool args from deltas', async () => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* openaiCompatToolCallStartChunks as any + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"https://example.com"}' } }], + }, + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 }, + } + yield { + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + } + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'fetched' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, + } + })() + }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [ + { id: 'http_request', name: 'http_request', description: 'd', parameters: {} } as any, + ], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'fetch' }], + createStream: createStream as any, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'http_request', + { url: 'https://example.com' }, + expect.not.objectContaining({ skipPostProcess: true }) + ) + }) + + it('finalizes truncated text when finish_reason is length without a tool call', async () => { + const createStream = vi.fn(async () => + (async function* () { + yield { + choices: [{ delta: { content: 'Truncated answer' }, finish_reason: 'length' }], + usage: { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 }, + } + })() + ) + const onComplete = vi.fn() + const timeSegments: TimeSegment[] = [] + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenAI', + request: { model: 'gpt-4.1', apiKey: 'k', messages: [] }, + basePayload: { model: 'gpt-4.1', max_tokens: 9 }, + messages: [{ role: 'user', content: 'answer' }], + createStream: createStream as never, + logger, + timeSegments, + onComplete, + }) + ) + + expect(events).toEqual([ + { type: 'text_delta', text: 'Truncated answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Truncated answer', + tokens: { input: 7, output: 9, total: 16 }, + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'length', + assistantContent: 'Truncated answer', + }) + }) + + it('rejects a length-capped turn containing a partial tool call', async () => { + const createStream = vi.fn(async () => + (async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_partial', + type: 'function', + function: { name: 'lookup', arguments: '{"query":' }, + }, + ], + }, + finish_reason: null, + }, + ], + } + yield { + choices: [{ delta: {}, finish_reason: 'length' }], + usage: { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 }, + } + })() + ) + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenAI', + request: { + model: 'gpt-4.1', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'gpt-4.1', max_tokens: 9 }, + messages: [{ role: 'user', content: 'answer' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toEqual(new Error('OpenAI returned tool calls with finish_reason length')) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'call_partial', + name: 'lookup', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'call_partial', + name: 'lookup', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts new file mode 100644 index 00000000000..7f766317be7 --- /dev/null +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -0,0 +1,595 @@ +/** + * Shared OpenAI Chat Completions streaming tool loop. + * + * Capability-honest: reasoning deltas only when the vendor streams them. + * Tool ends in completion order; abort → cancelled. + * + * Streams each model turn live (thinking + tool_call_start + `pending` text + * deltas) and classifies the turn with a `turn_end` event — same contract as + * the Anthropic/Gemini/Bedrock loops. The pump projects pending text to the + * answer channel only for final turns. Tool args are assembled from streamed + * `tool_calls` deltas (no blocking hybrid). + */ + +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + createOpenAICompatibleAgentEventStream, + type OpenAICompatAssembledToolCall, +} from '@/providers/openai-compat/stream-events' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + parseToolArguments, + type StreamingToolLoopComplete, + settleOpenTools, + terminateToolLoop, +} from '@/providers/streaming-tool-loop-shared' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +export type OpenAICompatCreateCompletion = ( + params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + options?: { signal?: AbortSignal } +) => Promise> + +export interface CreateOpenAICompatStreamingToolLoopOptions { + providerName: string + request: ProviderRequest + /** Base chat.completions payload (messages, tools, model, …) without stream. */ + basePayload: Record + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] + createStream: OpenAICompatCreateCompletion + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + /** + * When true, keep vendor reasoning fields on assistant history messages + * during the tool loop (required by DeepSeek thinking + tools). + */ + preserveAssistantReasoning?: boolean + onComplete: (result: StreamingToolLoopComplete) => void +} + +function nextForcedToolChoice( + forcedTools: string[], + usedForcedTools: string[] +): 'auto' | { type: 'function'; function: { name: string } } { + const remaining = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + if (remaining.length === 0) return 'auto' + return { type: 'function', function: { name: remaining[0] } } +} + +/** + * Multi-turn OpenAI-compat tool loop as an agent-events-v1 object stream. + */ +export function createOpenAICompatStreamingToolLoopStream( + options: CreateOpenAICompatStreamingToolLoopOptions +): ReadableStream { + const { + providerName, + request, + basePayload, + messages, + createStream, + logger, + timeSegments, + onComplete, + preserveAssistantReasoning = false, + } = options + const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeEventReader: ReadableStreamDefaultReader | undefined + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...messages] + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + const streamOpts = { signal: loopAbortController.signal } + + let currentToolChoice = basePayload.tool_choice + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + const reportProgress = () => { + const modelCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: modelCost.input, + output: modelCost.output, + total: modelCost.total + (toolCost || 0), + ...(toolCost ? { toolCost } : {}), + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } + + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const modelStart = Date.now() + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + const turnPayload = { + ...basePayload, + messages: currentMessages, + ...(finalSynthesis + ? { tools: undefined, tool_choice: 'none' } + : currentToolChoice !== undefined + ? { tool_choice: currentToolChoice } + : {}), + stream: true as const, + } + + const stream = await createStream( + turnPayload as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + streamOpts + ) + + let turnUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } + let turnContent = '' + let turnReasoningContent = '' + let turnReasoning = '' + let turnReasoningDetails: OpenRouterReasoningDetail[] | undefined + let turnFinishReason: string | undefined + let assembledTools: OpenAICompatAssembledToolCall[] = [] + const liveText: string[] = [] + let sawToolCallDelta = false + const inspectedStream = (async function* () { + for await (const chunk of stream) { + if ( + chunk.choices.some( + (choice) => + Array.isArray(choice.delta.tool_calls) && choice.delta.tool_calls.length > 0 + ) + ) { + sawToolCallDelta = true + } + yield chunk + } + })() + + const eventStream = createOpenAICompatibleAgentEventStream(inspectedStream, { + providerName, + emitToolCallStarts: true, + onComplete: (result) => { + turnUsage = { + prompt_tokens: result.usage.prompt_tokens ?? 0, + completion_tokens: result.usage.completion_tokens ?? 0, + total_tokens: result.usage.total_tokens ?? 0, + } + turnContent = result.content || '' + turnReasoningContent = result.reasoning_content || '' + turnReasoning = result.reasoning || '' + turnReasoningDetails = result.reasoning_details + turnFinishReason = result.finishReason + assembledTools = result.toolCalls ?? [] + }, + }) + + { + const reader = eventStream.getReader() + activeEventReader = reader + while (true) { + const { done, value } = await reader.read() + if (done) break + if (value.type === 'thinking_delta' || value.type === 'tool_call_start') { + if (value.type === 'tool_call_start') { + openToolStarts.set(value.id, value.name) + } + controller.enqueue(value) + } else if (value.type === 'text_delta') { + liveText.push(value.text) + // Live pending text: sinks render it now; the pump projects it + // to the answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: value.text, turn: 'pending' }) + } + } + activeEventReader = undefined + } + + const assembledPendingTools = assembledTools.filter((tc) => tc.id && tc.function?.name) + if (turnFinishReason === undefined) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} stream ended without finish_reason`) + } + if (finalSynthesis && assembledPendingTools.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} returned tool calls during final synthesis`) + } + if (assembledPendingTools.length > 0 && turnFinishReason !== 'tool_calls') { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `${providerName} returned tool calls with finish_reason ${turnFinishReason}` + ) + } + const cappedTextTurn = + turnFinishReason === 'length' && !sawToolCallDelta && openToolStarts.size === 0 + if ( + assembledPendingTools.length === 0 && + turnFinishReason !== 'stop' && + !cappedTextTurn + ) { + logger.warn('Rejecting incomplete model turn', { + finishReason: turnFinishReason, + }) + throw new Error(`${providerName} stream ended with finish_reason ${turnFinishReason}`) + } + const pendingTools = assembledPendingTools + const turnTag = pendingTools.length > 0 ? 'intermediate' : 'final' + const turnText = turnContent || liveText.join('') + // If the parser assembled text but we somehow missed deltas, still emit + // it before the boundary so the turn_end classification covers it. + if (turnText && liveText.length === 0) { + controller.enqueue({ type: 'text_delta', text: turnText, turn: 'pending' }) + } + controller.enqueue({ type: 'turn_end', turn: turnTag }) + content = turnText + + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) firstResponseTime = thisModelTime + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + enrichLastModelSegmentFromChatCompletions( + timeSegments, + { + choices: [ + { + message: { + content: turnText, + tool_calls: pendingTools, + ...(turnReasoningContent ? { reasoning_content: turnReasoningContent } : {}), + ...(turnReasoning ? { reasoning: turnReasoning } : {}), + ...(turnReasoningDetails?.length + ? { reasoning_details: turnReasoningDetails } + : {}), + }, + finish_reason: turnFinishReason, + }, + ], + usage: turnUsage, + }, + pendingTools, + { + model: request.model, + provider: providerName.toLowerCase(), + } + ) + tokens.input += turnUsage.prompt_tokens + tokens.output += turnUsage.completion_tokens + tokens.total += + turnUsage.total_tokens || turnUsage.prompt_tokens + turnUsage.completion_tokens + + if (pendingTools.length === 0) { + sawFinalTurn = true + break + } + + if ( + typeof currentToolChoice === 'object' && + currentToolChoice !== null && + pendingTools.length > 0 + ) { + const tracked = trackForcedToolUsage( + pendingTools, + currentToolChoice, + logger, + providerName.toLowerCase(), + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = tracked.hasUsedForcedTool + usedForcedTools = tracked.usedForcedTools + } + + const assistantHistory: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam & { + reasoning_content?: string + reasoning?: string + reasoning_details?: OpenRouterReasoningDetail[] + } = { + role: 'assistant', + content: turnText, + tool_calls: pendingTools, + } + if (preserveAssistantReasoning) { + if (turnReasoningContent) { + assistantHistory.reasoning_content = turnReasoningContent + } + if (turnReasoning) { + assistantHistory.reasoning = turnReasoning + } + } + if (turnReasoningDetails?.length) { + assistantHistory.reasoning_details = turnReasoningDetails + } + currentMessages.push(assistantHistory) + + const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + pendingTools.map(async (tc) => { + const toolCallStartTime = Date.now() + const toolName = tc.function.name + const toolUseId = tc.id + /** + * Malformed argument JSON must not execute the tool — running it + * with defaulted `{}` args could fire side effects with missing + * parameters. Fail the call and let the model react to the error. + */ + let toolArgs: Record + try { + toolArgs = parseToolArguments(tc.function.arguments, toolName) + } catch (error) { + const endTime = Date.now() + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return { + toolUseId, + toolName, + toolArgs: {}, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, `Invalid tool arguments for ${toolName}`), + }, + startTime: toolCallStartTime, + endTime, + duration: endTime - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + } + + try { + if (loopAbortController.signal.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: loopAbortController.signal, + }) + const toolCallEndTime = Date.now() + const value = { + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (result.success ? 'success' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: value.status, + }) + return value + } catch (error) { + const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'cancelled', + }) + throw error + } + if (isAbortError(error)) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) + const value = { + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: value.status, + }) + return value + } + }) + ) + + for (const value of orderedResults) { + timeSegments.push({ + type: 'tool', + name: value.toolName, + startTime: value.startTime, + endTime: value.endTime, + duration: value.duration, + toolCallId: value.toolUseId, + }) + + let resultContent: unknown + if (value.result.success) { + if (isRecordLike(value.result.output)) { + toolResults.push(value.result.output) + } + resultContent = value.result.output ?? null + } else { + resultContent = { + error: true, + message: value.result.error || 'Tool execution failed', + tool: value.toolName, + } + } + + toolCalls.push({ + name: value.toolName, + arguments: value.toolParams, + startTime: new Date(value.startTime).toISOString(), + endTime: new Date(value.endTime).toISOString(), + duration: value.duration, + result: resultContent, + success: value.result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: value.toolUseId, + content: JSON.stringify(resultContent), + }) + } + + toolsTime += Date.now() - toolsStartTime + + // Rotate / clear forced tool_choice so the model can answer after forced tools. + if (typeof currentToolChoice === 'object' && currentToolChoice !== null) { + if (hasUsedForcedTool && forcedTools.length > 0) { + const next = nextForcedToolChoice(forcedTools, usedForcedTools) + currentToolChoice = next + if (next === 'auto') { + logger.info('All forced tools have been used, switching to auto tool_choice') + } else { + logger.info(`Forcing next tool: ${next.function.name}`) + } + } + } + + iterationCount++ + } + + if (!sawFinalTurn) { + throw new Error(`${providerName} tool loop ended without a final response`) + } + + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error(`${providerName} streaming tool loop failed`, { + error: toError(cause).message, + }), + }) + } finally { + activeEventReader = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) + } + }, + async cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + await activeEventReader?.cancel(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, + }) +} diff --git a/apps/sim/providers/openai/core.cache-key.test.ts b/apps/sim/providers/openai/core.cache-key.test.ts new file mode 100644 index 00000000000..c12bfa34928 --- /dev/null +++ b/apps/sim/providers/openai/core.cache-key.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + * + * OpenAI prompt caching is automatic, so the only lever is routing stickiness: + * a `prompt_cache_key` that is stable for one agent block and distinct between + * blocks. Sharing a key across blocks with different prefixes would lower the + * hit rate rather than raise it. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeResponsesProviderRequest } from '@/providers/openai/core' +import type { ProviderRequest } from '@/providers/types' + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/utils', () => ({ + calculateCost: () => ({ input: 0, output: 0, total: 0 }), + sumToolCosts: () => 0, + enforceStrictSchema: (schema: unknown) => schema, + prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), + prepareToolsWithUsageControl: (tools: unknown[]) => ({ + tools, + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), + supportsReasoningEffort: () => false, +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +const COMPLETED_RESPONSE = { + id: 'resp_1', + status: 'completed', + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, +} + +describe('executeResponsesProviderRequest prompt cache key', () => { + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + // A Response body reads once, so each call needs its own instance. + fetchMock = vi.fn( + async () => + new Response(JSON.stringify(COMPLETED_RESPONSE), { + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + async function sentCacheKey(request: Partial): Promise { + await executeResponsesProviderRequest( + { + model: 'gpt-5.5', + apiKey: 'k', + messages: [{ role: 'user', content: 'hi' }], + ...request, + }, + { + providerId: 'openai', + providerLabel: 'OpenAI', + modelName: 'gpt-5.5', + endpoint: 'https://api.openai.com/v1/responses', + headers: { Authorization: 'Bearer k' }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + fetch: fetchMock as unknown as typeof fetch, + } + ) + const body = JSON.parse(fetchMock.mock.calls.at(-1)?.[1].body as string) + return body.prompt_cache_key + } + + it('sends the same key for repeat runs of one block', async () => { + const first = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const second = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + + expect(first).toBeTruthy() + expect(second).toBe(first) + }) + + it('sends a different key for another block in the same workflow', async () => { + const blockA = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const blockB = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-b' }) + + expect(blockB).not.toBe(blockA) + }) + + it('sends a different key for the same block id in another workflow', async () => { + const workflowOne = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const workflowTwo = await sentCacheKey({ workflowId: 'wf-2', blockId: 'block-a' }) + + expect(workflowTwo).not.toBe(workflowOne) + }) + + it('leaks no internal identifier into the key', async () => { + const key = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + + expect(key).not.toContain('wf-1') + expect(key).not.toContain('block-a') + }) + + it('omits the key when the caller has no stable identity', async () => { + expect(await sentCacheKey({ workflowId: 'wf-1' })).toBeUndefined() + expect(await sentCacheKey({ blockId: 'block-a' })).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts new file mode 100644 index 00000000000..a9b588951b0 --- /dev/null +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -0,0 +1,685 @@ +/** + * @vitest-environment node + * + * OpenAI Responses reasoning payload: summaries are requested on agent-events + * runs and whenever an explicit effort is set (staging parity), legacy runs + * without explicit effort keep a reasoning-free payload, and the + * unverified-organization 400 falls back to a summary-free retry. + */ +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import type { BlockTokens } from '@/executor/types' +import { executeResponsesProviderRequest } from '@/providers/openai/core' +import type { ProviderRequest } from '@/providers/types' +import { executeTool } from '@/tools' + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/utils', () => ({ + calculateCost: () => ({ input: 0, output: 0, total: 0 }), + sumToolCosts: () => 0, + enforceStrictSchema: (schema: unknown) => schema, + prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), + prepareToolsWithUsageControl: (tools: unknown[]) => ({ + tools, + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), + supportsReasoningEffort: (model: string) => ['gpt-5.5', 'o3'].includes(model), +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function sseResponse(events: unknown[]) { + const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('') + return new Response(body, { headers: { 'Content-Type': 'text/event-stream' } }) +} + +async function collect(stream: ReadableStream) { + const events: unknown[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +const COMPLETED_RESPONSE = { + id: 'resp_1', + status: 'completed', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'hello' }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, +} + +describe('executeResponsesProviderRequest reasoning payload', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE)) + }) + + function run(request: Partial & { model: string }) { + return executeResponsesProviderRequest( + { + apiKey: 'k', + messages: [{ role: 'user', content: 'hi' }], + ...request, + }, + { + providerId: 'openai', + providerLabel: 'OpenAI', + modelName: request.model, + endpoint: 'https://api.openai.com/v1/responses', + headers: { Authorization: 'Bearer k' }, + logger, + fetch: fetchMock as unknown as typeof fetch, + } + ) + } + + describe('agent-events runs', () => { + it('requests reasoning.summary auto when effort is auto', async () => { + await run({ model: 'gpt-5.5', agentEvents: true, reasoningEffort: 'auto' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto' }) + }) + + it('requests reasoning.summary auto when effort is unset', async () => { + await run({ model: 'o3', agentEvents: true }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto' }) + }) + + it('requests summary and effort when effort is explicit', async () => { + await run({ model: 'gpt-5.5', agentEvents: true, reasoningEffort: 'high' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto', effort: 'high' }) + }) + + it('retries without summary when the organization is not verified', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { + error: { + message: + "Your organization must be verified to generate reasoning summaries. Please go to: https://platform.openai.com/settings/organization/general and click on Verify Organization. (param: 'reasoning.summary')", + param: 'reasoning.summary', + code: 'unsupported_value', + }, + }, + 400 + ) + ) + .mockResolvedValueOnce(jsonResponse(COMPLETED_RESPONSE)) + + const result = await run({ model: 'o3', agentEvents: true, reasoningEffort: 'high' }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) + expect(retryBody.reasoning).toEqual({ effort: 'high' }) + expect((result as { content: string }).content).toBe('hello') + }) + + it('remembers summary rejection for later tool-loop turns', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { + error: { + message: + "Your organization must be verified to generate reasoning summaries. (param: 'reasoning.summary')", + }, + }, + 400 + ) + ) + .mockResolvedValueOnce( + jsonResponse({ + id: 'resp_tool', + status: 'completed', + output: [ + { type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: '{}' }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }) + ) + .mockResolvedValueOnce(jsonResponse(COMPLETED_RESPONSE)) + ;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } }) + + await run({ + model: 'o3', + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string).reasoning).toEqual({ + summary: 'auto', + }) + expect(JSON.parse(fetchMock.mock.calls[1][1].body as string).reasoning).toBeUndefined() + expect(JSON.parse(fetchMock.mock.calls[2][1].body as string).reasoning).toBeUndefined() + }) + + it('does not retry on unrelated 400s', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ error: { message: 'Invalid value for input' } }, 400) + ) + await expect(run({ model: 'o3', agentEvents: true })).rejects.toThrow('Invalid value') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('legacy runs (no agent events)', () => { + it('omits reasoning entirely when effort is unset', async () => { + await run({ model: 'o3' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toBeUndefined() + }) + + it('omits reasoning when effort is auto', async () => { + await run({ model: 'gpt-5.5', reasoningEffort: 'auto' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toBeUndefined() + }) + + it('keeps the staging payload (effort + summary) when effort is explicit', async () => { + // Pre-agent-events payloads always paired summary:'auto' with an + // explicit effort — legacy runs must stay byte-identical. + await run({ model: 'gpt-5.5', reasoningEffort: 'high' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto', effort: 'high' }) + }) + }) + + it('omits reasoning for non-reasoning models', async () => { + await run({ model: 'gpt-4o', agentEvents: true }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toBeUndefined() + }) + + describe('live streaming tool loop', () => { + it('streams reasoning and tool lifecycle in real time without a regeneration call', async () => { + const toolTurnResponse = { + id: 'resp_tool', + status: 'completed', + output: [ + { + id: 'rs_1', + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'I should search.' }], + }, + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, + } + const answerTurnResponse = { + id: 'resp_answer', + status: 'completed', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'Final answer', annotations: [] }], + }, + ], + usage: { input_tokens: 4, output_tokens: 5, total_tokens: 9 }, + } + + fetchMock + .mockResolvedValueOnce( + sseResponse([ + { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'I should search.', + }, + { + type: 'response.output_item.added', + output_index: 1, + sequence_number: 2, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + sequence_number: 3, + response: toolTurnResponse, + }, + ]) + ) + .mockResolvedValueOnce( + sseResponse([ + { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_2', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'I have the result.', + }, + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 1, + content_index: 0, + sequence_number: 2, + delta: 'Final answer', + logprobs: [], + }, + { + type: 'response.completed', + sequence_number: 3, + response: answerTurnResponse, + }, + ]) + ) + let resolveTool!: (value: { success: true; output: { results: string[] } }) => void + ;(executeTool as Mock).mockReturnValue( + new Promise((resolve) => { + resolveTool = resolve + }) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream; execution: { output: { content: string } } } + + const reader = result.stream.getReader() + const events: unknown[] = [] + for (let index = 0; index < 3; index++) { + const next = await reader.read() + expect(next.done).toBe(false) + events.push(next.value) + } + + expect(events).toEqual([ + { type: 'thinking_delta', text: 'I should search.' }, + { type: 'tool_call_start', id: 'call_1', name: 'exa_search' }, + { type: 'turn_end', turn: 'intermediate' }, + ]) + expect(fetchMock).toHaveBeenCalledTimes(1) + + resolveTool({ success: true, output: { results: ['hit'] } }) + while (true) { + const next = await reader.read() + if (next.done) break + events.push(next.value) + } + + expect(events).toEqual([ + { type: 'thinking_delta', text: 'I should search.' }, + { type: 'tool_call_start', id: 'call_1', name: 'exa_search' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'tool_call_end', id: 'call_1', name: 'exa_search', status: 'success' }, + { type: 'thinking_delta', text: 'I have the result.' }, + { type: 'text_delta', text: 'Final answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(fetchMock).toHaveBeenCalledTimes(2) + const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body as string) + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) + expect(firstBody.stream).toBe(true) + expect(secondBody.stream).toBe(true) + expect(secondBody.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'rs_1', type: 'reasoning' }), + expect.objectContaining({ call_id: 'call_1', type: 'function_call' }), + expect.objectContaining({ call_id: 'call_1', type: 'function_call_output' }), + ]) + ) + expect(result.execution.output.content).toBe('Final answer') + }) + + it('fails an unexpected tool AbortError and preserves completed usage', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: 'resp_tool', + status: 'completed', + output: [ + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, + }, + }, + ]) + ) + ;(executeTool as Mock).mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { + stream: ReadableStream + execution: { output: { tokens: BlockTokens } } + } + + await expect(collect(result.stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(result.execution.output.tokens).toEqual({ + input: 2, + output: 3, + total: 5, + cacheRead: 0, + cacheWrite: 0, + }) + }) + + it('makes the final answer turn after the maximum tool batches', async () => { + for (let index = 0; index < 5; index++) { + const callId = `call_${index}` + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: `fc_${index}`, + type: 'function_call', + call_id: callId, + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: `resp_${index}`, + status: 'completed', + output: [ + { + id: `fc_${index}`, + type: 'function_call', + call_id: callId, + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + } + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_text.delta', + item_id: 'msg_final', + output_index: 0, + content_index: 0, + sequence_number: 1, + delta: 'Answer after five tools', + logprobs: [], + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: 'resp_final', + status: 'completed', + output: [ + { + id: 'msg_final', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: 'Answer after five tools', annotations: [] }, + ], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + ;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } }) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream; execution: { output: { content: string } } } + + await collect(result.stream) + + expect(fetchMock).toHaveBeenCalledTimes(6) + expect(executeTool).toHaveBeenCalledTimes(5) + const finalBody = JSON.parse(fetchMock.mock.calls[5][1].body as string) + expect(finalBody.tool_choice).toBe('none') + expect(finalBody.tools).toBeUndefined() + expect(result.execution.output.content).toBe('Answer after five tools') + }) + + it('finalizes truncated text when max_output_tokens is reached without a tool call', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_text.delta', + item_id: 'msg_partial', + output_index: 0, + content_index: 0, + sequence_number: 1, + delta: 'Partial answer', + logprobs: [], + }, + { + type: 'response.incomplete', + sequence_number: 2, + response: { + id: 'resp_incomplete', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { + stream: ReadableStream + execution: { + output: { + content: string + tokens: { input: number; output: number; total: number } + } + } + } + + await expect(collect(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Partial answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(result.execution.output).toMatchObject({ + content: 'Partial answer', + tokens: { input: 1, output: 1, total: 2 }, + }) + }) + + it('rejects a max_output_tokens turn containing a partial tool call', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'fc_partial', + output_index: 0, + sequence_number: 2, + delta: '{"query":', + }, + { + type: 'response.incomplete', + sequence_number: 3, + response: { + id: 'resp_incomplete_tool', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [ + { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'exa_search', + arguments: '{"query":', + status: 'incomplete', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream } + + await expect(collect(result.stream)).rejects.toThrow( + 'OpenAI Responses stream incomplete: max_output_tokens' + ) + expect(executeTool).not.toHaveBeenCalled() + }) + + it('aborts the active Responses stream when its consumer cancels', async () => { + let requestSignal: AbortSignal | undefined + const encoder = new TextEncoder() + fetchMock.mockImplementation(async (_url: string, init: RequestInit) => { + requestSignal = init.signal as AbortSignal + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'Still working', + })}\n\n` + ) + ) + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } } + ) + }) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream } + + const reader = result.stream.getReader() + expect(await reader.read()).toEqual({ + done: false, + value: { type: 'thinking_delta', text: 'Still working' }, + }) + + await reader.cancel('client disconnected') + + expect(requestSignal?.aborted).toBe(true) + }) + }) +}) diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 913700ef5d7..df4ca1b6cd0 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -1,19 +1,28 @@ +import { createHash } from 'node:crypto' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type OpenAI from 'openai' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { createOpenAIResponsesStreamingToolLoopStream } from '@/providers/openai/streaming-tool-loop' +import { enrichLastModelSegmentFromOpenAIResponse } from '@/providers/openai/trace' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' -import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' import { - calculateCost, enforceStrictSchema, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, + supportsReasoningEffort, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -22,7 +31,6 @@ import { convertResponseOutputToInputItems, convertToolsToResponses, createReadableStreamFromResponses, - extractResponseReasoning, extractResponseText, extractResponseToolCalls, parseResponsesUsage, @@ -34,6 +42,22 @@ import { type PreparedTools = ReturnType type ToolChoice = PreparedTools['toolChoice'] +/** + * Stable routing key for OpenAI's prompt cache, scoped to one agent block. + * + * Per-block rather than per-workflow: two blocks in the same workflow have + * different prefixes, so sharing a key would pull them onto the same engine and + * lower the hit rate. Hashed so no internal identifier leaves the system. + * Returns `undefined` when the caller has no stable identity to key on. + */ +function buildPromptCacheKey(request: ProviderRequest): string | undefined { + if (!request.workflowId || !request.blockId) return undefined + return createHash('sha256') + .update(`${request.workflowId}:${request.blockId}`) + .digest('hex') + .slice(0, 32) +} + export interface ResponsesProviderConfig { providerId: string providerLabel: string @@ -95,13 +119,39 @@ export async function executeResponsesProviderRequest( model: config.modelName, } + /** + * OpenAI prompt caching is automatic and free, so there is nothing to toggle + * — but requests only hit a warm cache when they route to the same engine. + * A stable key per agent block sharpens that routing and is required for + * reliable matching on GPT-5.6+. + * + * `prompt_cache_key` is absent from the pinned SDK's typings, which is + * harmless: this body is a plain object posted through `fetch`, never + * `responses.create()`. Do not delete it as an unknown parameter. + */ + const promptCacheKey = buildPromptCacheKey(request) + if (promptCacheKey) basePayload.prompt_cache_key = promptCacheKey + if (request.temperature !== undefined) basePayload.temperature = request.temperature if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens - if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { - basePayload.reasoning = { - effort: request.reasoningEffort, - summary: 'auto', + /** + * Reasoning summaries feed Thinking chrome. They are requested when an + * explicit effort is set (pre-agent-events payload always paired + * `summary: 'auto'` with `effort` — kept for parity) and on agent-events + * runs even without an explicit effort. Summaries require OpenAI + * organization verification; see the strip-and-retry fallback in the + * request helpers below. + */ + if (supportsReasoningEffort(config.modelName)) { + const hasExplicitEffort = + request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto' + const reasoning: Record = { + ...(request.agentEvents === true || hasExplicitEffort ? { summary: 'auto' } : {}), + ...(hasExplicitEffort ? { effort: request.reasoningEffort } : {}), + } + if (Object.keys(reasoning).length > 0) { + basePayload.reasoning = reasoning } } @@ -112,11 +162,6 @@ export async function executeResponsesProviderRequest( } } - // Store response format config - for Azure with tools, we defer applying it until after tool calls complete - let deferredTextFormat: OpenAI.Responses.ResponseFormatTextJSONSchemaConfig | undefined - const hasTools = !!request.tools?.length - const isAzure = config.providerId === 'azure-openai' - if (request.responseFormat) { const isStrict = request.responseFormat.strict !== false const rawSchema = request.responseFormat.schema || request.responseFormat @@ -130,20 +175,11 @@ export async function executeResponsesProviderRequest( strict: isStrict, } - // Azure OpenAI has issues combining tools + response_format in the same request - // Defer the format until after tool calls complete for Azure - if (isAzure && hasTools) { - deferredTextFormat = textFormat - logger.info( - `Deferring JSON schema response format for ${config.providerLabel} (will apply after tool calls complete)` - ) - } else { - basePayload.text = { - ...((basePayload.text as Record) ?? {}), - format: textFormat, - } - logger.info(`Added JSON schema response format to ${config.providerLabel} request`) + basePayload.text = { + ...((basePayload.text as Record) ?? {}), + format: textFormat, } + logger.info(`Added JSON schema response format to ${config.providerLabel} request`) } const tools = request.tools?.length @@ -211,21 +247,76 @@ export async function executeResponsesProviderRequest( } } - const postResponses = async ( - body: Record - ): Promise => { + /** + * OpenAI rejects `reasoning.summary` with a 400 for organizations that have + * not completed verification. Summaries are best-effort chrome, so on that + * specific failure the request is retried once without the summary field + * rather than failing the run. + */ + const isReasoningSummaryVerificationError = (status: number, message: string): boolean => + status === 400 && + message.includes('reasoning.summary') && + message.toLowerCase().includes('verif') + + const stripReasoningSummary = (body: Record): Record | null => { + const reasoning = body.reasoning as Record | undefined + if (!reasoning || reasoning.summary === undefined) return null + const { summary: _summary, ...reasoningRest } = reasoning + const { reasoning: _reasoning, ...bodyRest } = body + return Object.keys(reasoningRest).length > 0 + ? { ...bodyRest, reasoning: reasoningRest } + : bodyRest + } + + let reasoningSummariesUnavailable = false + + const fetchResponsesWithSummaryFallback = async ( + requestedBody: Record, + abortSignal = request.abortSignal + ): Promise => { + const body = reasoningSummariesUnavailable + ? (stripReasoningSummary(requestedBody) ?? requestedBody) + : requestedBody const response = await fetchImpl(config.endpoint, { method: 'POST', headers: config.headers, body: JSON.stringify(body), - signal: request.abortSignal, + signal: abortSignal, }) + if (response.ok) return response - if (!response.ok) { - const message = await parseErrorResponse(response) + const message = await parseErrorResponse(response) + const strippedBody = isReasoningSummaryVerificationError(response.status, message) + ? stripReasoningSummary(body) + : null + if (!strippedBody) { throw new Error(`${config.providerLabel} API error (${response.status}): ${message}`) } + reasoningSummariesUnavailable = true + logger.warn( + `${config.providerLabel} rejected reasoning summaries (organization not verified); retrying without summary`, + { model: config.modelName } + ) + const retryResponse = await fetchImpl(config.endpoint, { + method: 'POST', + headers: config.headers, + body: JSON.stringify(strippedBody), + signal: abortSignal, + }) + if (!retryResponse.ok) { + const retryMessage = await parseErrorResponse(retryResponse) + throw new Error( + `${config.providerLabel} API error (${retryResponse.status}): ${retryMessage}` + ) + } + return retryResponse + } + + const postResponses = async ( + body: Record + ): Promise => { + const response = await fetchResponsesWithSummaryFallback(body) return response.json() } @@ -233,20 +324,63 @@ export async function executeResponsesProviderRequest( const providerStartTimeISO = new Date(providerStartTime).toISOString() try { - if (request.stream && (!tools || tools.length === 0)) { - logger.info(`Using streaming response for ${config.providerLabel} request`) + const hasActiveTools = Array.isArray(basePayload.tools) && basePayload.tools.length > 0 - const streamResponse = await fetchImpl(config.endpoint, { - method: 'POST', - headers: config.headers, - body: JSON.stringify(createRequestBody(initialInput, { stream: true })), - signal: request.abortSignal, + if (request.stream && hasActiveTools) { + logger.info(`Using live streaming tool loop for ${config.providerLabel} request`) + const timeSegments: TimeSegment[] = [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAIResponsesStreamingToolLoopStream({ + providerId: config.providerId, + providerLabel: config.providerLabel, + request, + initialInput, + initialToolChoice: responsesToolChoice, + forcedTools: preparedTools?.forcedTools, + createStream: (input, overrides, abortSignal) => + fetchResponsesWithSummaryFallback(createRequestBody(input, overrides), abortSignal), + logger, + timeSegments, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), }) + } - if (!streamResponse.ok) { - const message = await parseErrorResponse(streamResponse) - throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`) - } + if (request.stream && !hasActiveTools) { + logger.info(`Using streaming response for ${config.providerLabel} request`) + + const streamResponse = await fetchResponsesWithSummaryFallback( + createRequestBody(initialInput, { stream: true }) + ) const streamingResult = createStreamingExecution({ model: request.model, @@ -255,24 +389,22 @@ export async function executeResponsesProviderRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromResponses(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: usage?.promptTokens || 0, - output: usage?.completionTokens || 0, - total: usage?.totalTokens || 0, - } + createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { + const accumulator = createOpenAIUsageAccumulator() + addOpenAIUsage(accumulator, usage) - const costResult = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + output.content = content + output.tokens = buildOpenAIUsageTokens(accumulator) + output.cost = buildOpenAIUsageCost(request.model, accumulator) + + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + // Label honestly: these are reasoning *summaries*, not raw CoT. + segment.thinkingContent = thinking + } } finalizeTiming() @@ -313,12 +445,8 @@ export async function executeResponsesProviderRequest( ) const firstResponseTime = Date.now() - initialCallTime - const initialUsage = parseResponsesUsage(currentResponse.usage) - const tokens = { - input: initialUsage?.promptTokens || 0, - output: initialUsage?.completionTokens || 0, - total: initialUsage?.totalTokens || 0, - } + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, parseResponsesUsage(currentResponse.usage)) const toolCalls = [] const toolResults: Record[] = [] @@ -380,11 +508,24 @@ export async function executeResponsesProviderRequest( const toolName = toolCall.name try { - const toolArgs = toolCall.arguments ? JSON.parse(toolCall.arguments) : {} + const toolArgs = parseToolArguments(toolCall.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { - return null + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) @@ -403,6 +544,9 @@ export async function executeResponsesProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -422,13 +566,11 @@ export async function executeResponsesProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -439,10 +581,12 @@ export async function executeResponsesProviderRequest( toolCallId: toolCall.id, }) - let resultContent: Record - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output as Record + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -520,12 +664,7 @@ export async function executeResponsesProviderRequest( modelTime += thisModelTime - const usage = parseResponsesUsage(currentResponse.usage) - if (usage) { - tokens.input += usage.promptTokens - tokens.output += usage.completionTokens - tokens.total += usage.totalTokens - } + addOpenAIUsage(usage, parseResponsesUsage(currentResponse.usage)) iterationCount++ } @@ -542,172 +681,6 @@ export async function executeResponsesProviderRequest( ) } - // For Azure with deferred format: make a final call with the response format applied - // This happens whenever we have a deferred format, even if no tools were called - // (the initial call was made without the format, so we need to apply it now) - let appliedDeferredFormat = false - if (deferredTextFormat) { - logger.info( - `Applying deferred JSON schema response format for ${config.providerLabel} (iterationCount: ${iterationCount})` - ) - - const finalFormatStartTime = Date.now() - - // Determine what input to use for the formatted call - let formattedInput: ResponsesInputItem[] - - if (iterationCount > 0) { - // Tools were called - include the conversation history with tool results - const lastOutputItems = convertResponseOutputToInputItems(currentResponse.output) - if (lastOutputItems.length) { - currentInput.push(...lastOutputItems) - } - formattedInput = currentInput - } else { - // No tools were called - just retry the initial call with format applied - // Don't include the model's previous unformatted response - formattedInput = initialInput - } - - // Make final call with the response format - build payload without tools - const finalPayload: Record = { - model: config.modelName, - input: formattedInput, - text: { - ...((basePayload.text as Record) ?? {}), - format: deferredTextFormat, - }, - } - - // Copy over non-tool related settings - if (request.temperature !== undefined) finalPayload.temperature = request.temperature - if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens - if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { - finalPayload.reasoning = { - effort: request.reasoningEffort, - summary: 'auto', - } - } - if (request.verbosity !== undefined && request.verbosity !== 'auto') { - finalPayload.text = { - ...((finalPayload.text as Record) ?? {}), - verbosity: request.verbosity, - } - } - - currentResponse = await postResponses(finalPayload) - - const finalFormatEndTime = Date.now() - const finalFormatDuration = finalFormatEndTime - finalFormatStartTime - - timeSegments.push({ - type: 'model', - name: 'Final formatted response', - startTime: finalFormatStartTime, - endTime: finalFormatEndTime, - duration: finalFormatDuration, - }) - - modelTime += finalFormatDuration - - const finalUsage = parseResponsesUsage(currentResponse.usage) - if (finalUsage) { - tokens.input += finalUsage.promptTokens - tokens.output += finalUsage.completionTokens - tokens.total += finalUsage.totalTokens - } - - // Update content with the formatted response - const formattedText = extractResponseText(currentResponse.output) - if (formattedText) { - content = formattedText - } - - enrichLastModelSegmentFromOpenAIResponse( - timeSegments, - currentResponse, - formattedText, - extractResponseToolCalls(currentResponse.output), - { model: request.model } - ) - - appliedDeferredFormat = true - } - - // Skip streaming if we already applied deferred format - we have the formatted content - // Making another streaming call would lose the formatted response - if (request.stream && !appliedDeferredFormat) { - logger.info('Using streaming for final response after tool processing') - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - // For Azure with deferred format in streaming mode, include the format in the streaming call - const streamOverrides: Record = { stream: true, tool_choice: 'auto' } - if (deferredTextFormat) { - streamOverrides.text = { - ...((basePayload.text as Record) ?? {}), - format: deferredTextFormat, - } - } - - const streamResponse = await fetchImpl(config.endpoint, { - method: 'POST', - headers: config.headers, - body: JSON.stringify(createRequestBody(currentInput, streamOverrides)), - signal: request.abortSignal, - }) - - if (!streamResponse.ok) { - const message = await parseErrorResponse(streamResponse) - throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`) - } - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - createStream: ({ output }) => - createReadableStreamFromResponses(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + (usage?.promptTokens || 0), - output: tokens.output + (usage?.completionTokens || 0), - total: tokens.total + (usage?.totalTokens || 0), - } - - const streamCost = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) - - return streamingResult - } - const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -715,7 +688,13 @@ export async function executeResponsesProviderRequest( return { content, model: request.model, - tokens, + tokens: buildOpenAIUsageTokens(usage), + /** + * No tool cost here: `executeProviderRequest` re-derives it from + * `toolResults` for non-streaming responses, so folding it in would + * double-charge it. + */ + cost: buildOpenAIUsageCost(request.model, usage), toolCalls: toolCalls.length > 0 ? toolCalls : undefined, toolResults: toolResults.length > 0 ? toolResults : undefined, timing: { @@ -739,6 +718,10 @@ export async function executeResponsesProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, @@ -746,82 +729,3 @@ export async function executeResponsesProviderRequest( }) } } - -/** - * Determines a finish reason for an OpenAI Responses API response. - * Maps to conventional values: 'tool_calls' | 'length' | 'stop'. - */ -function deriveOpenAIFinishReason( - response: OpenAI.Responses.Response, - toolCalls: ResponsesToolCall[] -): string | undefined { - const incompleteReason = response.incomplete_details?.reason - if (incompleteReason === 'max_output_tokens') return 'length' - if (incompleteReason === 'content_filter') return 'content_filter' - if (toolCalls.length > 0) return 'tool_calls' - if (incompleteReason) return incompleteReason - if (response.status === 'failed') return 'error' - if (response.status === 'incomplete') return 'length' - if (response.status && response.status !== 'completed') return response.status - return 'stop' -} - -/** - * Enriches the last model segment with per-iteration content extracted from an - * OpenAI Responses API response: assistant text, tool calls, finish reason, - * and token usage for the iteration. - */ -function enrichLastModelSegmentFromOpenAIResponse( - timeSegments: TimeSegment[], - response: OpenAI.Responses.Response, - assistantText: string, - toolCallsInResponse: ResponsesToolCall[], - extras?: { - model?: string - ttft?: number - errorType?: string - errorMessage?: string - } -): void { - const toolCalls: IterationToolCall[] = toolCallsInResponse.map((tc) => ({ - id: tc.id, - name: tc.name, - arguments: - typeof tc.arguments === 'string' ? parseToolCallArguments(tc.arguments) : tc.arguments, - })) - - const usage = parseResponsesUsage(response.usage) - const thinkingContent = extractResponseReasoning(response.output) - - let cost: { input: number; output: number; total: number } | undefined - if (extras?.model && usage) { - const full = calculateCost( - extras.model, - usage.promptTokens, - usage.completionTokens, - usage.cachedTokens > 0 - ) - cost = { input: full.input, output: full.output, total: full.total } - } - - enrichLastModelSegment(timeSegments, { - assistantContent: assistantText || undefined, - thinkingContent: thinkingContent || undefined, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - finishReason: deriveOpenAIFinishReason(response, toolCallsInResponse), - tokens: usage - ? { - input: usage.promptTokens, - output: usage.completionTokens, - total: usage.totalTokens, - ...(usage.cachedTokens > 0 && { cacheRead: usage.cachedTokens }), - ...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }), - } - : undefined, - cost, - provider: 'openai', - ttft: extras?.ttft, - errorType: extras?.errorType, - errorMessage: extras?.errorMessage, - }) -} diff --git a/apps/sim/providers/openai/streaming-tool-loop.ts b/apps/sim/providers/openai/streaming-tool-loop.ts new file mode 100644 index 00000000000..7f9a75788d7 --- /dev/null +++ b/apps/sim/providers/openai/streaming-tool-loop.ts @@ -0,0 +1,579 @@ +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type OpenAI from 'openai' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { enrichLastModelSegmentFromOpenAIResponse } from '@/providers/openai/trace' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' +import { + extractResponseText, + extractResponseToolCalls, + isMaxOutputTokensIncompleteResponse, + isResponseFunctionCallEvent, + iterateResponsesStreamEvents, + parseResponsesUsage, + type ResponsesInputItem, + type ResponsesToolCall, + type ResponsesToolChoice, + responseContainsFunctionCall, +} from '@/providers/openai/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + parseToolArguments, + type StreamingToolLoopComplete, + settleOpenTools, + terminateToolLoop, +} from '@/providers/streaming-tool-loop-shared' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' + +export type CreateOpenAIResponsesStream = ( + input: ResponsesInputItem[], + overrides: Record, + abortSignal: AbortSignal +) => Promise + +type OpenAIStreamingToolLoopComplete = Omit & { + tokens: ReturnType +} + +export interface CreateOpenAIResponsesStreamingToolLoopOptions { + providerId: string + providerLabel: string + request: ProviderRequest + initialInput: ResponsesInputItem[] + initialToolChoice?: ResponsesToolChoice + forcedTools?: string[] + createStream: CreateOpenAIResponsesStream + logger: Logger + timeSegments: TimeSegment[] + onComplete: (result: OpenAIStreamingToolLoopComplete) => void +} + +interface OpenAIResponsesTurn { + response: OpenAI.Responses.Response + text: string + toolCalls: ResponsesToolCall[] +} + +interface OpenAIToolExecutionResult { + toolCall: ResponsesToolCall + toolName: string + toolParams: Record + result: { + success: boolean + output?: Record + error?: string + } + startTime: number + endTime: number + duration: number +} + +/** + * Streams one OpenAI Responses turn and returns its assembled terminal response. + */ +async function streamResponsesTurn( + response: Response, + controller: ReadableStreamDefaultController, + openTools: Map, + abortSignal?: AbortSignal +): Promise { + let terminalResponse: OpenAI.Responses.Response | undefined + let streamedText = '' + let sawFunctionCall = false + + for await (const event of iterateResponsesStreamEvents(response, abortSignal)) { + if (isResponseFunctionCallEvent(event)) { + sawFunctionCall = true + } + if (event.type === 'error') { + throw new Error(event.message || 'OpenAI Responses stream error') + } + if (event.type === 'response.failed') { + throw new Error(event.response.error?.message || 'OpenAI Responses stream failed') + } + if (event.type === 'response.incomplete') { + const reason = event.response.incomplete_details?.reason ?? 'unknown' + if ( + !isMaxOutputTokensIncompleteResponse(event.response) || + sawFunctionCall || + openTools.size > 0 || + responseContainsFunctionCall(event.response) + ) { + throw new Error(`OpenAI Responses stream incomplete: ${reason}`) + } + terminalResponse = event.response + continue + } + + if (event.type === 'response.reasoning_summary_text.delta') { + if (event.delta) { + controller.enqueue({ type: 'thinking_delta', text: event.delta }) + } + continue + } + + if (event.type === 'response.output_text.delta') { + if (event.delta) { + streamedText += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'pending' }) + } + continue + } + if (event.type === 'response.refusal.delta') { + if (event.delta) { + streamedText += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'pending' }) + } + continue + } + + if (event.type === 'response.output_item.added' && event.item.type === 'function_call') { + const id = event.item.call_id + const name = event.item.name + if (!openTools.has(id)) { + openTools.set(id, name) + controller.enqueue({ type: 'tool_call_start', id, name }) + } + continue + } + + if (event.type === 'response.completed') { + terminalResponse = event.response + } + } + + if (!terminalResponse) { + throw new Error('OpenAI Responses stream ended without a terminal response') + } + + const toolCalls = extractResponseToolCalls(terminalResponse.output) + const text = streamedText || extractResponseText(terminalResponse.output) + + return { response: terminalResponse, text, toolCalls } +} + +/** + * Finalizes one tool execution and emits its terminal lifecycle event. + */ +function completeToolExecution( + controller: ReadableStreamDefaultController, + openTools: Map, + toolCall: ResponsesToolCall, + toolParams: Record, + result: OpenAIToolExecutionResult['result'], + startTime: number, + status: ToolCallEndStatus +): OpenAIToolExecutionResult { + const endTime = Date.now() + openTools.delete(toolCall.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolCall.id, + name: toolCall.name, + status, + }) + return { + toolCall, + toolName: toolCall.name, + toolParams, + result, + startTime, + endTime, + duration: endTime - startTime, + } +} + +/** + * Executes one assembled OpenAI function call. + */ +async function executeOpenAIToolCall(options: { + toolCall: ResponsesToolCall + request: ProviderRequest + controller: ReadableStreamDefaultController + openTools: Map + logger: Logger +}): Promise { + const { toolCall, request, controller, openTools, logger } = options + const startTime = Date.now() + let toolArgs: Record + + try { + toolArgs = parseToolArguments(toolCall.arguments, toolCall.name) + } catch (error) { + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, `Invalid tool arguments for ${toolCall.name}`), + }, + startTime, + 'error' + ) + } + + const tool = request.tools?.find((candidate) => candidate.id === toolCall.name) + if (!tool) { + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: `Tool not found: ${toolCall.name}`, + }, + startTime, + 'error' + ) + } + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolCall.name, executionParams, { + signal: request.abortSignal, + }) + return completeToolExecution( + controller, + openTools, + toolCall, + toolParams, + result, + startTime, + result.success ? 'success' : 'error' + ) + } catch (error) { + if (request.abortSignal?.aborted) { + completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution cancelled'), + }, + startTime, + 'cancelled' + ) + throw error + } + if (isAbortError(error)) { + completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution aborted unexpectedly'), + }, + startTime, + 'error' + ) + throw error + } + + logger.error('Error processing OpenAI tool call:', { + error, + toolName: toolCall.name, + }) + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime, + 'error' + ) + } +} + +/** + * Multi-turn OpenAI Responses tool loop as an `agent-events-v1` object stream. + */ +export function createOpenAIResponsesStreamingToolLoopStream( + options: CreateOpenAIResponsesStreamingToolLoopOptions +): ReadableStream { + const { + providerId, + providerLabel, + request, + initialInput, + initialToolChoice, + createStream, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } + + const loopRequest: ProviderRequest = { + ...request, + abortSignal: loopAbortController.signal, + } + + return new ReadableStream({ + start(controller) { + void (async () => { + const currentInput = [...initialInput] + const usedForcedTools = new Set() + const usage = createOpenAIUsageAccumulator() + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openTools = new Map() + let currentToolChoice = initialToolChoice + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens: buildOpenAIUsageTokens(usage), + cost: buildOpenAIUsageCost(request.model, usage, toolCost), + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } + + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + settleOpenTools(controller, openTools, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const modelStart = Date.now() + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + const streamResponse = await createStream( + currentInput, + { + stream: true, + ...(finalSynthesis + ? { tools: undefined, tool_choice: 'none' } + : currentToolChoice !== undefined + ? { tool_choice: currentToolChoice } + : {}), + }, + loopAbortController.signal + ) + const turn = await streamResponsesTurn( + streamResponse, + controller, + openTools, + loopAbortController.signal + ) + const modelEnd = Date.now() + const modelDuration = modelEnd - modelStart + const turnUsage = parseResponsesUsage(turn.response.usage) + const reachedToolLimit = iterationCount >= MAX_TOOL_ITERATIONS + const toolsExecutable = turn.response.status === 'completed' && !reachedToolLimit + const executableTools = toolsExecutable ? turn.toolCalls : [] + + if (turn.toolCalls.length > 0 && !toolsExecutable) { + logger.warn('Skipping OpenAI tool execution', { + status: turn.response.status, + toolCount: turn.toolCalls.length, + reachedToolLimit, + }) + settleOpenTools(controller, openTools, 'error') + } + + const executableToolIds = new Set(executableTools.map((toolCall) => toolCall.id)) + for (const [id, name] of openTools) { + if (!executableToolIds.has(id)) { + openTools.delete(id) + controller.enqueue({ type: 'tool_call_end', id, name, status: 'error' }) + } + } + + for (const toolCall of executableTools) { + if (!openTools.has(toolCall.id)) { + openTools.set(toolCall.id, toolCall.name) + controller.enqueue({ + type: 'tool_call_start', + id: toolCall.id, + name: toolCall.name, + }) + } + } + + const turnKind = executableTools.length > 0 ? 'intermediate' : 'final' + content = turn.text + controller.enqueue({ type: 'turn_end', turn: turnKind }) + + modelTime += modelDuration + modelCalls++ + if (modelCalls === 1) { + firstResponseTime = modelDuration + } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: modelDuration, + }) + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + turn.response, + turn.text, + turn.toolCalls, + { model: request.model } + ) + + addOpenAIUsage(usage, turnUsage) + + if (executableTools.length === 0) { + break + } + + currentInput.push(...turn.response.output) + + if (typeof currentToolChoice === 'object') { + for (const toolCall of executableTools) { + if (forcedTools.includes(toolCall.name)) { + usedForcedTools.add(toolCall.name) + } + } + } + + const toolsStart = Date.now() + const orderedResults = await Promise.all( + executableTools.map((toolCall) => + executeOpenAIToolCall({ + toolCall, + request: loopRequest, + controller, + openTools, + logger, + }) + ) + ) + + for (const result of orderedResults) { + timeSegments.push({ + type: 'tool', + name: result.toolName, + startTime: result.startTime, + endTime: result.endTime, + duration: result.duration, + toolCallId: result.toolCall.id, + }) + + const resultContent = result.result.success + ? (result.result.output ?? null) + : { + error: true, + message: result.result.error || 'Tool execution failed', + tool: result.toolName, + } + + if (result.result.success && isRecordLike(result.result.output)) { + toolResults.push(result.result.output) + } + + toolCalls.push({ + name: result.toolName, + arguments: result.toolParams, + startTime: new Date(result.startTime).toISOString(), + endTime: new Date(result.endTime).toISOString(), + duration: result.duration, + result: resultContent, + success: result.result.success, + }) + + currentInput.push({ + type: 'function_call_output', + call_id: result.toolCall.id, + output: JSON.stringify(resultContent), + }) + } + + toolsTime += Date.now() - toolsStart + + if (typeof currentToolChoice === 'object') { + const remaining = forcedTools.filter((toolName) => !usedForcedTools.has(toolName)) + currentToolChoice = + remaining.length > 0 ? { type: 'function', name: remaining[0] } : 'auto' + if (remaining.length === 0) { + logger.info('All forced tools have been used, switching to auto tool_choice') + } else { + logger.info(`Forcing next tool: ${remaining[0]}`) + } + } + + iterationCount++ + } + + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error(`Error in ${providerLabel} streaming tool loop`, { + providerId, + error: cause, + }), + }) + } finally { + request.abortSignal?.removeEventListener('abort', abortFromRequest) + } + })().catch((error) => { + // `start` cannot be async (the loop must not block the first pull), so + // a throw escaping the IIFE would surface as an unhandled rejection. + logger.error(`Unhandled failure in ${providerLabel} streaming tool loop`, { + providerId, + error: toError(error).message, + }) + }) + }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, + }) +} diff --git a/apps/sim/providers/openai/trace.ts b/apps/sim/providers/openai/trace.ts new file mode 100644 index 00000000000..f0147d461d8 --- /dev/null +++ b/apps/sim/providers/openai/trace.ts @@ -0,0 +1,82 @@ +import type OpenAI from 'openai' +import type { IterationToolCall } from '@/executor/types' +import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' +import { + extractResponseReasoning, + parseResponsesUsage, + type ResponsesToolCall, + toOpenAIModelUsage, +} from '@/providers/openai/utils' +import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' +import type { TimeSegment } from '@/providers/types' + +/** + * Maps a Responses API terminal response to Sim's conventional finish reason. + */ +function deriveOpenAIFinishReason( + response: OpenAI.Responses.Response, + toolCalls: ResponsesToolCall[] +): string | undefined { + const incompleteReason = response.incomplete_details?.reason + if (incompleteReason === 'max_output_tokens') return 'length' + if (incompleteReason === 'content_filter') return 'content_filter' + if (toolCalls.length > 0) return 'tool_calls' + if (incompleteReason) return incompleteReason + if (response.status === 'failed') return 'error' + if (response.status === 'incomplete') return 'length' + if (response.status && response.status !== 'completed') return response.status + return 'stop' +} + +/** + * Enriches the latest model segment from a terminal Responses API response. + */ +export function enrichLastModelSegmentFromOpenAIResponse( + timeSegments: TimeSegment[], + response: OpenAI.Responses.Response, + assistantText: string, + toolCallsInResponse: ResponsesToolCall[], + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const toolCalls: IterationToolCall[] = toolCallsInResponse.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + arguments: parseToolCallArguments(toolCall.arguments), + })) + + const usage = parseResponsesUsage(response.usage) + const thinkingContent = extractResponseReasoning(response.output) + + let cost: { input: number; output: number; total: number } | undefined + if (extras?.model && usage) { + const full = priceModelUsage(extras.model, toOpenAIModelUsage(usage), LIST_PRICE_POLICY) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: deriveOpenAIFinishReason(response, toolCallsInResponse), + tokens: usage + ? { + input: usage.promptTokens, + output: usage.completionTokens, + total: usage.totalTokens, + ...(usage.cachedTokens > 0 && { cacheRead: usage.cachedTokens }), + ...(usage.cacheWriteTokens > 0 && { cacheWrite: usage.cacheWriteTokens }), + ...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }), + } + : undefined, + cost, + provider: 'openai', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} diff --git a/apps/sim/providers/openai/usage.test.ts b/apps/sim/providers/openai/usage.test.ts new file mode 100644 index 00000000000..6d546a7db5f --- /dev/null +++ b/apps/sim/providers/openai/usage.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' +import type { ResponsesUsageTokens } from '@/providers/openai/utils' +import { calculateCost } from '@/providers/utils' + +/** input $2.50/M, cachedInput $1.25/M, output $10.00/M. */ +const MODEL = 'gpt-4o' +/** input $2.50/M, cachedInput $0.25/M, output $15.00/M — bills cache writes. */ +const CACHE_WRITE_MODEL = 'gpt-5.6-terra' + +/** + * Builds a Responses usage payload. `promptTokens` is inclusive of cached and + * written tokens, matching what {@link parseResponsesUsage} emits. + */ +function responsesUsage(partial: Partial): ResponsesUsageTokens { + const promptTokens = partial.promptTokens ?? 0 + const completionTokens = partial.completionTokens ?? 0 + return { + promptTokens, + completionTokens, + totalTokens: partial.totalTokens ?? promptTokens + completionTokens, + cachedTokens: partial.cachedTokens ?? 0, + cacheWriteTokens: partial.cacheWriteTokens ?? 0, + reasoningTokens: partial.reasoningTokens ?? 0, + } +} + +describe('OpenAI usage aggregation', () => { + it('matches plain list pricing when nothing was cached', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 12_345, completionTokens: 6_789 })) + + const uncached = calculateCost(MODEL, 12_345, 6_789) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 12_345, + output: 6_789, + total: 19_134, + cacheRead: 0, + cacheWrite: 0, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: uncached.input, + output: uncached.output, + total: uncached.total, + }) + }) + + it('bills cached tokens at the cached rate instead of the full input rate', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 1_000_000, cachedTokens: 600_000, completionTokens: 0 }) + ) + + /** 400k uncached at $2.50/M plus 600k cached at $1.25/M. */ + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: 1.75, + output: 0, + total: 1.75, + }) + expect(calculateCost(MODEL, 1_000_000, 0).input).toBe(2.5) + }) + + it('reports cache reads separately while keeping the prompt total intact', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 1_000, cachedTokens: 800, completionTokens: 100 }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 200, + output: 100, + total: 1_100, + cacheRead: 800, + cacheWrite: 0, + }) + }) + + it('bills GPT-5.6 cache writes at 1.25x the uncached input rate', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + completionTokens: 0, + }) + ) + + /** 1M written at $2.50/M x 1.25. */ + expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ + input: 3.125, + output: 0, + total: 3.125, + }) + }) + + it('aggregates uncached, cached, written, and output tokens in one turn', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000_000, + cachedTokens: 600_000, + cacheWriteTokens: 200_000, + completionTokens: 100_000, + }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 200_000, + output: 100_000, + total: 1_100_000, + cacheRead: 600_000, + cacheWrite: 200_000, + }) + /** 0.5 uncached + 0.15 cached + 0.625 written input, 1.5 output. */ + expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ + input: 1.275, + output: 1.5, + total: 2.775, + }) + }) + + it('accumulates each tool-loop turn exactly once', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000, completionTokens: 100 })) + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 2_000, cachedTokens: 1_500, completionTokens: 200 }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 1_500, + output: 300, + total: 3_300, + cacheRead: 1_500, + cacheWrite: 0, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: 0.005625, + output: 0.003, + total: 0.008625, + }) + }) + + it('ignores turns that reported no usage', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000, completionTokens: 100 })) + addOpenAIUsage(usage, undefined) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 1_000, + output: 100, + total: 1_100, + cacheRead: 0, + cacheWrite: 0, + }) + }) + + it('adds tool cost to the total and only reports the field when charged', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000_000, completionTokens: 0 })) + + expect(buildOpenAIUsageCost(MODEL, usage, 0.25)).toMatchObject({ + input: 2.5, + total: 2.75, + toolCost: 0.25, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).not.toHaveProperty('toolCost') + }) + + it('does not charge for cache tokens a vendor payload over-reported', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000, + cachedTokens: 900, + cacheWriteTokens: 400, + completionTokens: 0, + }) + ) + + expect(buildOpenAIUsageTokens(usage)).toMatchObject({ + input: 0, + cacheRead: 900, + cacheWrite: 100, + }) + }) +}) diff --git a/apps/sim/providers/openai/usage.ts b/apps/sim/providers/openai/usage.ts new file mode 100644 index 00000000000..3d9cdb362e7 --- /dev/null +++ b/apps/sim/providers/openai/usage.ts @@ -0,0 +1,128 @@ +import type { BlockTokens } from '@/executor/types' +import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy' +import { + OPENAI_CACHE_WRITE_MULTIPLIER, + type ResponsesUsageTokens, + splitOpenAIUsage, +} from '@/providers/openai/utils' +import type { ModelPricing } from '@/providers/types' + +export interface OpenAIUsageAccumulator { + /** + * Tokens billed at the base input rate. EXCLUDES cache reads and writes, + * which OpenAI reports as subsets of `input_tokens` and which are billed at + * their own rates. + */ + input: number + output: number + /** Every token the request consumed, cache reads and writes included. */ + total: number + cacheRead: number + cacheWrite: number +} + +interface OpenAIUsageCost { + input: number + output: number + total: number + toolCost?: number + pricing: ModelPricing +} + +function roundedCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Creates an empty accumulator for one OpenAI provider request. + */ +export function createOpenAIUsageAccumulator(): OpenAIUsageAccumulator { + return { + input: 0, + output: 0, + total: 0, + cacheRead: 0, + cacheWrite: 0, + } +} + +/** + * Adds one Responses API turn's usage without counting cache tokens as + * uncached input. + * + * Normalization goes through {@link splitOpenAIUsage} so that subtracting the + * cache buckets out of the prompt total — and clamping a vendor payload that + * reports more cache tokens than it processed — stays in one place. + */ +export function addOpenAIUsage( + accumulator: OpenAIUsageAccumulator, + usage: ResponsesUsageTokens | undefined +): void { + if (!usage) return + + const split = splitOpenAIUsage(usage) + + accumulator.input += split.input + accumulator.output += split.output + accumulator.cacheRead += split.cacheRead + accumulator.cacheWrite += split.cacheWrite + accumulator.total += usage.totalTokens +} + +/** + * Builds the block token shape. `total` is OpenAI's own reported total, which + * already counts cache reads and writes alongside the uncached remainder. + */ +export function buildOpenAIUsageTokens( + accumulator: OpenAIUsageAccumulator +): Required> { + return { + input: accumulator.input, + output: accumulator.output, + total: accumulator.total, + cacheRead: accumulator.cacheRead, + cacheWrite: accumulator.cacheWrite, + } +} + +/** + * Builds the normalized usage for one OpenAI request. + * + * `input` is already the uncached remainder because {@link addOpenAIUsage} + * subtracted the cache buckets per turn — unlike Anthropic, whose + * `input_tokens` arrives exclusive of them. + */ +export function buildOpenAIModelUsage(accumulator: OpenAIUsageAccumulator): ModelUsage { + return { + input: accumulator.input, + output: accumulator.output, + cacheRead: accumulator.cacheRead, + cacheWrites: [ + { tokens: accumulator.cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }, + ], + } +} + +/** + * Prices one OpenAI request, cache reads and writes included, through the + * shared pricing function. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function buildOpenAIUsageCost( + model: string, + accumulator: OpenAIUsageAccumulator, + toolCost = 0 +): OpenAIUsageCost { + const cost = priceModelUsage(model, buildOpenAIModelUsage(accumulator), LIST_PRICE_POLICY) + + return { + input: cost.input, + output: cost.output, + total: roundedCost(cost.total + toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: cost.pricing, + } +} diff --git a/apps/sim/providers/openai/utils.stream.test.ts b/apps/sim/providers/openai/utils.stream.test.ts new file mode 100644 index 00000000000..bed841d345a --- /dev/null +++ b/apps/sim/providers/openai/utils.stream.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromResponses } from '@/providers/openai/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +function sseResponse(events: Array<{ event?: string; data: unknown }>): Response { + const body = events + .map((e) => { + const lines = [] + if (e.event) lines.push(`event: ${e.event}`) + lines.push(`data: ${JSON.stringify(e.data)}`) + return `${lines.join('\n')}\n\n` + }) + .join('') + return new Response(body, { headers: { 'Content-Type': 'text/event-stream' } }) +} + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromResponses', () => { + it('emits reasoning summary deltas as thinking and output_text as final text', async () => { + const onComplete = vi.fn() + const response = sseResponse([ + { + event: 'response.reasoning_summary_text.delta', + data: { type: 'response.reasoning_summary_text.delta', delta: 'Summary thought. ' }, + }, + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Answer' }, + }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 4, output_tokens: 6 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response, onComplete)) + expect(events).toEqual([ + { type: 'thinking_delta', text: 'Summary thought. ' }, + { type: 'text_delta', text: 'Answer', turn: 'final' }, + ]) + expect(onComplete.mock.calls[0][0]).toBe('Answer') + expect(onComplete.mock.calls[0][2]).toBe('Summary thought. ') + }) + + it('stays text-only when no reasoning summary events arrive', async () => { + const response = sseResponse([ + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Hi' }, + }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + }, + ]) + const events = await collectEvents(createReadableStreamFromResponses(response)) + expect(events).toEqual([{ type: 'text_delta', text: 'Hi', turn: 'final' }]) + }) + + it('streams refusal text as the model answer', async () => { + const response = sseResponse([ + { + event: 'response.refusal.delta', + data: { type: 'response.refusal.delta', delta: "I can't help with that." }, + }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 1, output_tokens: 5 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response)) + expect(events).toEqual([{ type: 'text_delta', text: "I can't help with that.", turn: 'final' }]) + }) + + it('finalizes truncated text when max_output_tokens is the only incomplete condition', async () => { + const onComplete = vi.fn() + const response = sseResponse([ + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Truncated answer' }, + }, + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response, onComplete)) + + expect(events).toEqual([{ type: 'text_delta', text: 'Truncated answer', turn: 'final' }]) + expect(onComplete).toHaveBeenCalledWith( + 'Truncated answer', + { + promptTokens: 3, + completionTokens: 5, + totalTokens: 8, + cachedTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }, + undefined + ) + }) + + it('rejects a max_output_tokens response with a partial function call', async () => { + const response = sseResponse([ + { + event: 'response.output_item.added', + data: { + type: 'response.output_item.added', + item: { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'lookup', + arguments: '', + status: 'in_progress', + }, + }, + }, + { + event: 'response.function_call_arguments.delta', + data: { + type: 'response.function_call_arguments.delta', + item_id: 'fc_partial', + output_index: 0, + delta: '{"query":', + }, + }, + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + }, + }, + }, + ]) + + await expect(collectEvents(createReadableStreamFromResponses(response))).rejects.toThrow( + 'OpenAI Responses stream incomplete: max_output_tokens' + ) + }) + + it('continues rejecting non-token-cap incomplete responses', async () => { + const response = sseResponse([ + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'content_filter' }, + output: [], + }, + }, + }, + ]) + + await expect(collectEvents(createReadableStreamFromResponses(response))).rejects.toThrow( + 'OpenAI Responses stream incomplete: content_filter' + ) + }) +}) diff --git a/apps/sim/providers/openai/utils.test.ts b/apps/sim/providers/openai/utils.test.ts index ba943f3f4aa..e8c97793b32 100644 --- a/apps/sim/providers/openai/utils.test.ts +++ b/apps/sim/providers/openai/utils.test.ts @@ -1,8 +1,82 @@ /** * @vitest-environment node */ +import type OpenAI from 'openai' import { describe, expect, it } from 'vitest' -import { buildResponsesInputFromMessages } from '@/providers/openai/utils' +import { + buildResponsesInputFromMessages, + parseResponsesUsage, + toOpenAIModelUsage, +} from '@/providers/openai/utils' + +describe('parseResponsesUsage', () => { + it('reads cache writes, which GPT-5.6+ bills at a premium', () => { + const usage = parseResponsesUsage({ + input_tokens: 1000, + output_tokens: 100, + total_tokens: 1100, + input_tokens_details: { cached_tokens: 600, cache_write_tokens: 200 }, + output_tokens_details: { reasoning_tokens: 0 }, + } as OpenAI.Responses.ResponseUsage) + + expect(usage).toMatchObject({ promptTokens: 1000, cachedTokens: 600, cacheWriteTokens: 200 }) + }) + + it('reports zero cache writes on model families that do not charge for them', () => { + const usage = parseResponsesUsage({ + input_tokens: 1000, + output_tokens: 100, + total_tokens: 1100, + input_tokens_details: { cached_tokens: 600 }, + output_tokens_details: { reasoning_tokens: 0 }, + } as OpenAI.Responses.ResponseUsage) + + expect(usage?.cacheWriteTokens).toBe(0) + }) +}) + +describe('toOpenAIModelUsage', () => { + /** + * OpenAI reports cached and written tokens as subsets of `input_tokens`; + * double-counting them would over-bill every cached request. + */ + it('subtracts cache buckets out of the prompt total', () => { + const usage = toOpenAIModelUsage({ + promptTokens: 1000, + completionTokens: 100, + totalTokens: 1100, + cachedTokens: 600, + cacheWriteTokens: 200, + reasoningTokens: 0, + }) + + expect(usage).toEqual({ + input: 200, + output: 100, + cacheRead: 600, + cacheWrites: [{ tokens: 200, inputRateMultiplier: 1.25 }], + }) + }) + + /** + * OpenAI has shipped payloads where reads plus writes exceeded the prompt + * total. Left unclamped that bills more input than the request contained. + */ + it('never bills more cache tokens than the request reported', () => { + const usage = toOpenAIModelUsage({ + promptTokens: 4583, + completionTokens: 15, + totalTokens: 4598, + cachedTokens: 3945, + cacheWriteTokens: 4580, + reasoningTokens: 0, + }) + + expect(usage.input).toBe(0) + expect(usage.cacheRead).toBe(3945) + expect(usage.cacheWrites?.[0].tokens).toBe(4583 - 3945) + }) +}) describe('buildResponsesInputFromMessages', () => { it('should convert user message files to Responses multipart content', () => { diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index 495a0eae05b..fa6e295f071 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -1,40 +1,141 @@ -import { createLogger } from '@sim/logger' import type OpenAI from 'openai' +import { Stream } from 'openai/streaming' import { buildOpenAIMessageContent } from '@/providers/attachments' +import type { ModelUsage } from '@/providers/cost-policy' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { Message } from '@/providers/types' -const logger = createLogger('ResponsesUtils') - export interface ResponsesUsageTokens { + /** Total input tokens. INCLUDES {@link cachedTokens}, per the OpenAI usage schema. */ promptTokens: number completionTokens: number totalTokens: number + /** Input tokens served from cache — a subset of {@link promptTokens}. */ cachedTokens: number + /** Input tokens written to cache. Billed at 1.25x on GPT-5.6+, free before it. */ + cacheWriteTokens: number reasoningTokens: number } +/** GPT-5.6 and later bill cache writes at 1.25x the uncached input rate. */ +export const OPENAI_CACHE_WRITE_MULTIPLIER = 1.25 + +/** + * One OpenAI response's tokens split into the buckets that price differently. + * `input` is the uncached remainder, so `input + cacheRead + cacheWrite` is the + * prompt total OpenAI reported. + */ +export interface OpenAITokenSplit { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +/** + * Splits a cache-inclusive OpenAI prompt total into its billing buckets. + * + * `cached_tokens` and `cache_write_tokens` are subsets of `input_tokens`, so the + * uncached remainder is the subtraction — the opposite of Anthropic, whose + * `input_tokens` already excludes cache tokens. + * + * Both buckets are clamped to what the request actually contained. OpenAI has + * shipped payloads where reads plus writes summed past the prompt total, so + * without this a vendor reporting bug becomes an overcharge. + */ +export function splitOpenAIUsage(usage: ResponsesUsageTokens): OpenAITokenSplit { + const promptTokens = Math.max(0, usage.promptTokens) + const cacheRead = Math.min(Math.max(0, usage.cachedTokens), promptTokens) + const cacheWrite = Math.min(Math.max(0, usage.cacheWriteTokens), promptTokens - cacheRead) + + return { + input: promptTokens - cacheRead - cacheWrite, + output: usage.completionTokens, + cacheRead, + cacheWrite, + } +} + +/** Adapts a {@link splitOpenAIUsage} result to the shared pricing shape. */ +export function toOpenAIModelUsage(usage: ResponsesUsageTokens): ModelUsage { + const { input, output, cacheRead, cacheWrite } = splitOpenAIUsage(usage) + + return { + input, + output, + cacheRead, + cacheWrites: [{ tokens: cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }], + } +} + export interface ResponsesToolCall { id: string name: string arguments: string } -export type ResponsesInputItem = - | { - role: 'system' | 'user' | 'assistant' - content: string | OpenAI.Responses.ResponseInputContent[] - } - | { - type: 'function_call' - call_id: string - name: string - arguments: string +export type ResponsesStreamEvent = OpenAI.Responses.ResponseStreamEvent + +export type ResponsesInputItem = OpenAI.Responses.ResponseInputItem + +/** + * Identifies the one incomplete Responses status that still contains a valid + * truncated answer: the configured output-token cap was reached. + */ +export function isMaxOutputTokensIncompleteResponse(response: OpenAI.Responses.Response): boolean { + return ( + response.status === 'incomplete' && response.incomplete_details?.reason === 'max_output_tokens' + ) +} + +/** + * Checks the terminal Responses output for a function call, including one + * whose arguments or status remain incomplete. + */ +export function responseContainsFunctionCall(response: OpenAI.Responses.Response): boolean { + return response.output.some((item) => item.type === 'function_call') +} + +/** + * Detects documented Responses stream events that prove function-call + * generation started, even when the terminal output omits the partial item. + */ +export function isResponseFunctionCallEvent(event: ResponsesStreamEvent): boolean { + return ( + (event.type === 'response.output_item.added' && event.item.type === 'function_call') || + event.type === 'response.function_call_arguments.delta' || + event.type === 'response.function_call_arguments.done' + ) +} + +/** + * Parses a Responses API SSE body with the official OpenAI stream decoder. + */ +export async function* iterateResponsesStreamEvents( + response: Response, + abortSignal?: AbortSignal +): AsyncGenerator { + const parserController = new AbortController() + const abortParser = () => parserController.abort(abortSignal?.reason) + + if (abortSignal?.aborted) { + abortParser() + } else { + abortSignal?.addEventListener('abort', abortParser, { once: true }) + } + + try { + const stream = Stream.fromSSEResponse(response, parserController) + for await (const event of stream) { + yield event } - | { - type: 'function_call_output' - call_id: string - output: string + } finally { + abortSignal?.removeEventListener('abort', abortParser) + if (!parserController.signal.aborted) { + parserController.abort() } + } +} export interface ResponsesToolDefinition { type: 'function' @@ -43,6 +144,8 @@ export interface ResponsesToolDefinition { parameters?: Record } +export type ResponsesToolChoice = 'auto' | 'none' | { type: 'function'; name: string } + /** * Converts chat-style messages into Responses API input items. */ @@ -135,7 +238,7 @@ export function toResponsesToolChoice( | { type: 'tool'; name: string } | { type: 'any'; any: { model: string; name: string } } | undefined -): 'auto' | 'none' | { type: 'function'; name: string } | undefined { +): ResponsesToolChoice | undefined { if (!toolChoice) { return undefined } @@ -175,17 +278,10 @@ function extractTextFromMessageItem(item: unknown): string { continue } - if ((part.type === 'output_text' || part.type === 'text') && typeof part.text === 'string') { + if (part.type === 'output_text' && typeof part.text === 'string') { textParts.push(part.text) - continue - } - - if (part.type === 'output_json') { - if (typeof part.text === 'string') { - textParts.push(part.text) - } else if (part.json !== undefined) { - textParts.push(JSON.stringify(part.json)) - } + } else if (part.type === 'refusal' && typeof part.refusal === 'string') { + textParts.push(part.refusal) } } @@ -227,12 +323,8 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput const parts: string[] = [] for (const item of output) { if (!item || item.type !== 'reasoning') continue - const summary = (item as unknown as { summary?: Array<{ text?: string | null } | null> }) - .summary - if (!Array.isArray(summary)) continue - for (const entry of summary) { - const text = entry?.text - if (typeof text === 'string' && text.length > 0) parts.push(text) + for (const entry of item.summary) { + if (entry.text.length > 0) parts.push(entry.text) } } return parts.join('\n\n') @@ -244,75 +336,7 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput export function convertResponseOutputToInputItems( output: OpenAI.Responses.ResponseOutputItem[] ): ResponsesInputItem[] { - if (!Array.isArray(output)) { - return [] - } - - const items: ResponsesInputItem[] = [] - for (const item of output) { - if (!isRecord(item)) { - continue - } - - if (item.type === 'message') { - const text = extractTextFromMessageItem(item) - if (text) { - items.push({ - role: 'assistant', - content: text, - }) - } - - // Handle Chat Completions-style tool_calls nested under message items - const toolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [] - for (const toolCall of toolCalls) { - const tc = toolCall as Record - const fn = tc.function as Record | undefined - const callId = tc.id as string | undefined - const name = (fn?.name ?? tc.name) as string | undefined - if (!callId || !name) { - continue - } - - const argumentsValue = - typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {}) - - items.push({ - type: 'function_call', - call_id: callId, - name, - arguments: argumentsValue, - }) - } - - continue - } - - if (item.type === 'function_call') { - const fc = item as OpenAI.Responses.ResponseFunctionToolCall - const callId = fc.call_id ?? (typeof item.id === 'string' ? item.id : undefined) - const name = - fc.name ?? - (isRecord(item.function) && typeof item.function.name === 'string' - ? item.function.name - : undefined) - if (!callId || !name) { - continue - } - - const argumentsValue = - typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {}) - - items.push({ - type: 'function_call', - call_id: callId, - name, - arguments: argumentsValue, - }) - } - } - - return items + return Array.isArray(output) ? output : [] } /** @@ -334,13 +358,7 @@ export function extractResponseToolCalls( if (item.type === 'function_call') { const fc = item as OpenAI.Responses.ResponseFunctionToolCall - const callId = fc.call_id ?? (typeof item.id === 'string' ? item.id : undefined) - const name = - fc.name ?? - (isRecord(item.function) && typeof item.function.name === 'string' - ? item.function.name - : undefined) - if (!callId || !name) { + if (!fc.call_id || !fc.name) { continue } @@ -348,33 +366,10 @@ export function extractResponseToolCalls( typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {}) toolCalls.push({ - id: callId, - name, + id: fc.call_id, + name: fc.name, arguments: argumentsValue, }) - continue - } - - // Handle Chat Completions-style tool_calls nested under message items - if (item.type === 'message' && Array.isArray(item.tool_calls)) { - for (const toolCall of item.tool_calls) { - const tc = toolCall as Record - const fn = tc.function as Record | undefined - const callId = tc.id as string | undefined - const name = (fn?.name ?? tc.name) as string | undefined - if (!callId || !name) { - continue - } - - const argumentsValue = - typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {}) - - toolCalls.push({ - id: callId, - name, - arguments: argumentsValue, - }) - } } } @@ -396,7 +391,12 @@ export function parseResponsesUsage( const inputTokens = usage.input_tokens ?? 0 const outputTokens = usage.output_tokens ?? 0 - const cachedTokens = usage.input_tokens_details?.cached_tokens ?? 0 + const details = usage.input_tokens_details as + | { cached_tokens?: number | null; cache_write_tokens?: number | null } + | undefined + const cachedTokens = details?.cached_tokens ?? 0 + // Added for GPT-5.6; absent (and free) on earlier model families. + const cacheWriteTokens = details?.cache_write_tokens ?? 0 const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0 const completionTokens = Math.max(outputTokens, reasoningTokens) const totalTokens = inputTokens + completionTokens @@ -406,132 +406,103 @@ export function parseResponsesUsage( completionTokens, totalTokens, cachedTokens, + cacheWriteTokens, reasoningTokens, } } /** - * Creates a ReadableStream from a Responses API SSE stream. + * Creates an agent-events-v1 stream from a Responses API SSE stream. + * + * Capability-honest: emits `thinking_delta` only for streamable reasoning + * *summary* deltas (not encrypted_content / raw CoT). If the API only + * surfaces reasoning at completion, live thinking may be empty — traces still + * use extractResponseReasoning post-hoc. */ export function createReadableStreamFromResponses( response: Response, - onComplete?: (content: string, usage?: ResponsesUsageTokens) => void -): ReadableStream { - let fullContent = '' - let finalUsage: ResponsesUsageTokens | undefined - let activeEventType: string | undefined - const encoder = new TextEncoder() - - return new ReadableStream({ - async start(controller) { - const reader = response.body?.getReader() - if (!reader) { - controller.close() - return - } - - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { - continue + onComplete?: (content: string, usage?: ResponsesUsageTokens, thinking?: string) => void +): ReadableStream { + const streamAbortController = new AbortController() + + return new ReadableStream({ + start(controller) { + void (async () => { + let fullContent = '' + let fullThinking = '' + let finalUsage: ResponsesUsageTokens | undefined + let completed = false + let sawFunctionCall = false + + try { + for await (const event of iterateResponsesStreamEvents( + response, + streamAbortController.signal + )) { + if (isResponseFunctionCallEvent(event)) { + sawFunctionCall = true } - - if (trimmed.startsWith('event:')) { - activeEventType = trimmed.slice(6).trim() - continue + if (event.type === 'error') { + throw new Error(event.message || 'OpenAI Responses stream error') } - - if (!trimmed.startsWith('data:')) { - continue + if (event.type === 'response.failed') { + throw new Error(event.response.error?.message || 'OpenAI Responses stream failed') } - - const data = trimmed.slice(5).trim() - if (data === '[DONE]') { + if (event.type === 'response.incomplete') { + const reason = event.response.incomplete_details?.reason ?? 'unknown' + if ( + !isMaxOutputTokensIncompleteResponse(event.response) || + sawFunctionCall || + responseContainsFunctionCall(event.response) + ) { + throw new Error(`OpenAI Responses stream incomplete: ${reason}`) + } + finalUsage = parseResponsesUsage(event.response.usage) + completed = true continue } - - let event: Record - try { - event = JSON.parse(data) - } catch (error) { - logger.debug('Skipping non-JSON response stream chunk', { - data: data.slice(0, 200), - error, - }) + if (event.type === 'response.reasoning_summary_text.delta') { + if (event.delta) { + fullThinking += event.delta + controller.enqueue({ type: 'thinking_delta', text: event.delta }) + } continue } - - const eventType = event?.type ?? activeEventType - - if ( - eventType === 'response.error' || - eventType === 'error' || - eventType === 'response.failed' - ) { - const errorObj = event.error as Record | undefined - const message = (errorObj?.message as string) || 'Responses API stream error' - controller.error(new Error(message)) - return - } - - if ( - eventType === 'response.output_text.delta' || - eventType === 'response.output_json.delta' - ) { - let deltaText = '' - const delta = event.delta as string | Record | undefined - if (typeof delta === 'string') { - deltaText = delta - } else if (delta && typeof delta.text === 'string') { - deltaText = delta.text - } else if (delta && delta.json !== undefined) { - deltaText = JSON.stringify(delta.json) - } else if (event.json !== undefined) { - deltaText = JSON.stringify(event.json) - } else if (typeof event.text === 'string') { - deltaText = event.text + if (event.type === 'response.output_text.delta') { + if (event.delta) { + fullContent += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'final' }) } - - if (deltaText.length > 0) { - fullContent += deltaText - controller.enqueue(encoder.encode(deltaText)) + continue + } + if (event.type === 'response.refusal.delta') { + if (event.delta) { + fullContent += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'final' }) } + continue } - - if (eventType === 'response.completed') { - const responseObj = event.response as Record | undefined - const usageData = (responseObj?.usage ?? event.usage) as - | OpenAI.Responses.ResponseUsage - | undefined - finalUsage = parseResponsesUsage(usageData) + if (event.type === 'response.completed') { + finalUsage = parseResponsesUsage(event.response.usage) + completed = true } } - } - if (onComplete) { - onComplete(fullContent, finalUsage) - } + if (!completed) { + throw new Error('OpenAI Responses stream ended without a completed response') + } - controller.close() - } catch (error) { - controller.error(error) - } finally { - reader.releaseLock() - } + onComplete?.(fullContent, finalUsage, fullThinking || undefined) + controller.close() + } catch (error) { + if (!streamAbortController.signal.aborted) { + controller.error(error) + } + } + })() + }, + cancel(reason) { + streamAbortController.abort(reason) }, }) } diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 8c26f611b8c..c611a5ee7f8 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -70,7 +70,9 @@ vi.mock('@/providers/utils', () => ({ generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), })) +import type { StreamingExecution } from '@/executor/types' import { openRouterProvider } from '@/providers/openrouter/index' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface Usage { @@ -89,12 +91,25 @@ function textResponse( } } -function toolCallResponse(name: string, args: Record, id = 'call_1') { +function toolCallResponse( + name: string, + args: Record, + id = 'call_1', + assistant: { + content?: string | null + reasoning?: string + reasoning_details?: OpenRouterReasoningDetail[] + } = {} +) { return { choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning !== undefined ? { reasoning: assistant.reasoning } : {}), + ...(assistant.reasoning_details !== undefined + ? { reasoning_details: assistant.reasoning_details } + : {}), tool_calls: [ { id, type: 'function', function: { name, arguments: JSON.stringify(args) } }, ], @@ -129,6 +144,9 @@ describe('openRouterProvider.executeRequest', () => { mockCreate.mockReset() mockExecuteTool.mockReset() mockSupportsNative.mockResolvedValue(false) + mockCreateStream.mockReturnValue( + new ReadableStream({ start: (controller) => controller.close() }) + ) }) it('requires an API key', async () => { @@ -233,6 +251,85 @@ describe('openRouterProvider.executeRequest', () => { }) }) + it('replays OpenRouter reasoning_details unchanged with assistant content', async () => { + const reasoningDetails: OpenRouterReasoningDetail[] = [ + { + type: 'reasoning.summary', + summary: 'Need current weather.', + id: 'reasoning-1', + format: 'anthropic-claude-v1', + index: 0, + }, + { + type: 'reasoning.encrypted', + data: 'opaque-data', + id: 'reasoning-2', + format: 'anthropic-claude-v1', + index: 1, + }, + { + type: 'reasoning.text', + text: 'Call the weather tool.', + signature: null, + id: 'reasoning-3', + format: 'anthropic-claude-v1', + index: 2, + }, + ] + mockCreate + .mockResolvedValueOnce( + toolCallResponse('get_weather', { city: 'SF' }, 'call_1', { + content: 'I will check.', + reasoning_details: reasoningDetails, + }) + ) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will check.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"SF"}' }, + }, + ], + reasoning_details: reasoningDetails, + }) + }) + + it('does not add OpenRouter reasoning fields when the provider omitted them', async () => { + mockCreate + .mockResolvedValueOnce( + toolCallResponse('get_weather', { city: 'SF' }, 'call_1', { + content: 'I will check.', + }) + ) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).not.toHaveProperty('reasoning') + expect(assistant).not.toHaveProperty('reasoning_details') + }) + it('applies native structured outputs (json_schema + require_parameters) when no tools are active', async () => { mockSupportsNative.mockResolvedValue(true) mockCreate.mockResolvedValueOnce(textResponse('{"x":1}')) @@ -329,8 +426,40 @@ describe('openRouterProvider.executeRequest', () => { expect(res).toHaveProperty('execution.output.model', 'anthropic/claude-3.5-sonnet') }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate + .mockResolvedValueOnce(toolCallResponse('get_weather', { city: 'SF' })) + .mockResolvedValueOnce( + textResponse('It is sunny', { prompt_tokens: 20, completion_tokens: 6, total_tokens: 26 }) + ) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + const res = (await openRouterProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [tool('get_weather')], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(res.execution.output).toMatchObject({ + content: 'It is sunny', + tokens: { input: 28, output: 10, total: 38 }, + toolCalls: { count: 1 }, + }) + const reader = res.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'It is sunny', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + }) + it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { - mockCreate.mockResolvedValue(toolCallResponse('looping', {})) + mockCreate.mockImplementation((payload) => + payload.tool_choice === 'none' + ? textResponse('iteration limit answer') + : toolCallResponse('looping', {}) + ) mockExecuteTool.mockResolvedValue({ success: true, output: {} }) const res = (await openRouterProvider.executeRequest({ @@ -338,9 +467,11 @@ describe('openRouterProvider.executeRequest', () => { tools: [tool('looping')], })) as ProviderResponse - expect(mockCreate).toHaveBeenCalledTimes(11) + expect(mockCreate).toHaveBeenCalledTimes(12) expect(mockExecuteTool).toHaveBeenCalledTimes(10) expect(res.toolCalls?.length).toBe(10) + expect(res.content).toBe('iteration limit answer') + expect(mockCreate.mock.calls.at(-1)?.[0]).toMatchObject({ tool_choice: 'none' }) }) it('wraps SDK errors in a ProviderError', async () => { diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 8821483fa4e..41146d1599c 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -1,17 +1,28 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import type { + ChatCompletionCreateParamsStreaming, + ChatCompletionMessage, +} from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + createOpenAICompatAssistantHistory, + type OpenAICompatAssistantHistoryMessage, +} from '@/providers/openai-compat/assistant-history' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -34,6 +45,12 @@ import { executeTool } from '@/tools' const logger = createLogger('OpenRouterProvider') +type OpenRouterAssistantMessage = ChatCompletionMessage & { + reasoning?: string + reasoning_content?: string + reasoning_details?: OpenRouterReasoningDetail[] +} + /** * Applies structured output configuration to a payload based on model capabilities. * Uses json_schema with require_parameters for supported models, falls back to json_object with prompt instructions. @@ -169,6 +186,7 @@ export const openRouterProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -264,10 +282,25 @@ export const openRouterProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -285,6 +318,9 @@ export const openRouterProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (OpenRouter):', { error: toError(error).message, @@ -307,26 +343,27 @@ export const openRouterProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message as + | OpenRouterAssistantMessage + | undefined + if (assistantMessage) { + const assistantHistory: OpenAICompatAssistantHistoryMessage & { + reasoning_details?: OpenRouterReasoningDetail[] + } = createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + if (Array.isArray(assistantMessage.reasoning_details)) { + assistantHistory.reasoning_details = assistantMessage.reasoning_details + } + currentMessages.push(assistantHistory) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -337,10 +374,12 @@ export const openRouterProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -418,84 +457,61 @@ export const openRouterProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'openrouter' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'openrouter', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = { - ...payload, - messages: [...currentMessages], - tool_choice: 'auto', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'openrouter' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -551,6 +567,45 @@ export const openRouterProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -568,7 +623,7 @@ export const openRouterProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -591,6 +646,10 @@ export const openRouterProvider: ProviderConfig = { } logger.error('Error in OpenRouter request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/openrouter/reasoning.ts b/apps/sim/providers/openrouter/reasoning.ts new file mode 100644 index 00000000000..2ebaa05bc91 --- /dev/null +++ b/apps/sim/providers/openrouter/reasoning.ts @@ -0,0 +1,35 @@ +export type OpenRouterReasoningFormat = + | 'unknown' + | 'openai-responses-v1' + | 'azure-openai-responses-v1' + | 'xai-responses-v1' + | 'anthropic-claude-v1' + | 'google-gemini-v1' + +interface OpenRouterReasoningDetailBase { + format?: OpenRouterReasoningFormat + id?: string | null + index?: number +} + +export type OpenRouterReasoningDetail = + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.encrypted' + data: string + }) + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.summary' + summary: string + }) + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.text' + signature?: string | null + text?: string | null + }) + +/** Returns the user-displayable text from a documented OpenRouter reasoning block. */ +export function getOpenRouterReasoningDetailText(detail: OpenRouterReasoningDetail): string { + if (detail.type === 'reasoning.summary') return detail.summary + if (detail.type === 'reasoning.text' && typeof detail.text === 'string') return detail.text + return '' +} diff --git a/apps/sim/providers/openrouter/utils.ts b/apps/sim/providers/openrouter/utils.ts index 51637f5148d..2f8a7850fc9 100644 --- a/apps/sim/providers/openrouter/utils.ts +++ b/apps/sim/providers/openrouter/utils.ts @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' const logger = createLogger('OpenRouterUtils') @@ -86,9 +88,14 @@ export async function supportsNativeStructuredOutputs(modelId: string): Promise< export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'OpenRouter', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'OpenRouter', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } export function checkForForcedToolUsage( diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 3988d3e96db..7e9cf12da79 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -126,6 +130,7 @@ export const sakanaProvider: ProviderConfig = { // backends reject a request that carries both `response_format` and active // `tools`/`tool_choice`. Defer the schema until after the tool loop completes. const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload } @@ -150,26 +155,31 @@ export const sakanaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromSakanaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -254,7 +264,7 @@ export const sakanaProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) // Every tool_call in the assistant message must be answered by a matching @@ -293,6 +303,9 @@ export const sakanaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -312,7 +325,7 @@ export const sakanaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -327,11 +340,9 @@ export const sakanaProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -342,10 +353,12 @@ export const sakanaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -448,104 +461,69 @@ export const sakanaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'sakana' } ) - } - } catch (error) { - logger.error('Error in Sakana request:', { error }) - throw error - } - - if (request.stream) { - logger.info('Using streaming for final Sakana response after tool processing') - - // The tool loop is complete: this final pass only produces the textual answer. - // Force `tool_choice: 'none'` so the model cannot emit fresh tool calls that the - // text-only stream adapter would silently drop. - const streamingPayload: any = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - streamingPayload.parallel_tool_calls = false - } - const streamResponse = await sakana.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + } + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.parallel_tool_calls = false + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await sakana.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'sakana' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Sakana request:', { error }) + throw error } // Tools were active, so `response_format` was withheld from the loop. Make one final // tool-free call to obtain the structured response now that the tool work is done. - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -591,6 +569,52 @@ export const sakanaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -608,7 +632,7 @@ export const sakanaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -622,6 +646,10 @@ export const sakanaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/sakana/utils.ts b/apps/sim/providers/sakana/utils.ts index ede98301a12..ba8b42329cf 100644 --- a/apps/sim/providers/sakana/utils.ts +++ b/apps/sim/providers/sakana/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Sakana AI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Sakana AI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromSakanaStream( sakanaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(sakanaStream, 'Sakana', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(sakanaStream, { + providerName: 'Sakana', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts new file mode 100644 index 00000000000..d7d1ea5c4c9 --- /dev/null +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -0,0 +1,572 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { basetenProvider } from '@/providers/baseten/index' +import { cerebrasProvider } from '@/providers/cerebras' +import { fireworksProvider } from '@/providers/fireworks/index' +import { kimiProvider } from '@/providers/kimi' +import { metaProvider } from '@/providers/meta' +import { nvidiaProvider } from '@/providers/nvidia' +import { openRouterProvider } from '@/providers/openrouter/index' +import { sakanaProvider } from '@/providers/sakana' +import { togetherProvider } from '@/providers/together/index' +import type { ProviderConfig, ProviderResponse, ProviderToolConfig } from '@/providers/types' +import { xAIProvider } from '@/providers/xai' +import { zaiProvider } from '@/providers/zai' + +const { mockCreate, mockExecuteTool } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@cerebras/cerebras_cloud_sdk', () => ({ + Cerebras: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 1 })) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, + getModelCapabilities: vi.fn(), + getProviderModels: vi.fn((provider: string) => [`${provider}/test-model`]), + getProviderDefaultModel: vi.fn((provider: string) => `${provider}/test-model`), +})) + +vi.mock('@/providers/tool-schema-adapter', () => ({ + adaptOpenAIChatToolSchema: vi.fn((tool: ProviderToolConfig) => ({ + type: 'function', + function: { + name: tool.id, + description: tool.description, + parameters: tool.parameters, + }, + })), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 1, output: 2, total: 3 })), + enforceStrictSchema: vi.fn((schema) => schema), + generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), + prepareToolExecution: vi.fn((_tool, args) => ({ + toolParams: args, + executionParams: args, + })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [{ type: 'function', function: { name: 'lookup' } }], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + })), + sumToolCosts: vi.fn(() => 4), + trackForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), +})) + +vi.mock('@/providers/baseten/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/fireworks/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/openrouter/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/together/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/cerebras/utils', () => ({ + createReadableStreamFromCerebrasStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/kimi/utils', () => ({ + createReadableStreamFromKimiStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/meta/utils', () => ({ + createReadableStreamFromMetaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/nvidia/utils', () => ({ + createReadableStreamFromNvidiaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/sakana/utils', () => ({ + createReadableStreamFromSakanaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/zai/utils', () => ({ + createReadableStreamFromZaiStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/xai/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromXAIStream: vi.fn(() => createEmptyStream()), + createResponseFormatPayload: vi.fn(() => ({})), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +interface ToolCall { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +const TOOL: ProviderToolConfig = { + id: 'lookup', + name: 'lookup', + description: 'Looks up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, +} + +const PROVIDERS = [ + { name: 'Cerebras', provider: cerebrasProvider, model: 'cerebras/test-model' }, + { name: 'Z.ai', provider: zaiProvider, model: 'zai/test-model' }, + { name: 'Sakana', provider: sakanaProvider, model: 'sakana/test-model' }, + { name: 'Kimi', provider: kimiProvider, model: 'kimi/test-model' }, + { name: 'Meta', provider: metaProvider, model: 'meta/test-model' }, + { name: 'NVIDIA', provider: nvidiaProvider, model: 'nvidia/test-model' }, + { name: 'xAI', provider: xAIProvider, model: 'xai/test-model' }, +] as const + +const REASONING_HISTORY_PROVIDERS = [ + { + name: 'Cerebras', + provider: cerebrasProvider, + model: 'cerebras/test-model', + field: 'reasoning', + }, + { + name: 'Kimi', + provider: kimiProvider, + model: 'kimi/test-model', + field: 'reasoning_content', + }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + field: 'reasoning_content', + }, + { + name: 'xAI', + provider: xAIProvider, + model: 'xai/test-model', + field: 'reasoning_content', + }, + { + name: 'Z.ai', + provider: zaiProvider, + model: 'zai/test-model', + field: 'reasoning_content', + }, +] as const + +const CAPPED_PROVIDERS = [ + { + name: 'Cerebras', + provider: cerebrasProvider, + model: 'cerebras/test-model', + disablesTools: 'none', + }, + { name: 'Z.ai', provider: zaiProvider, model: 'zai/test-model', disablesTools: 'omit' }, + { + name: 'Sakana', + provider: sakanaProvider, + model: 'sakana/test-model', + disablesTools: 'none', + }, + { name: 'Kimi', provider: kimiProvider, model: 'kimi/test-model', disablesTools: 'omit' }, + { name: 'Meta', provider: metaProvider, model: 'meta/test-model', disablesTools: 'omit' }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + disablesTools: 'none', + }, +] as const + +const STRUCTURED_OUTPUT_PROVIDERS = [ + { + name: 'Baseten', + provider: basetenProvider, + model: 'baseten/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Fireworks', + provider: fireworksProvider, + model: 'fireworks/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'OpenRouter', + provider: openRouterProvider, + model: 'openrouter/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Together', + provider: togetherProvider, + model: 'together/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Meta', + provider: metaProvider, + model: 'meta/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + responseFormatType: 'json_schema', + disablesTools: 'none', + }, + { + name: 'Sakana', + provider: sakanaProvider, + model: 'sakana/test-model', + responseFormatType: 'json_schema', + disablesTools: 'none', + }, + { + name: 'Z.ai', + provider: zaiProvider, + model: 'zai/test-model', + responseFormatType: 'json_object', + disablesTools: 'omit', + }, +] as const + +function createEmptyStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) +} + +function toolCall(id: string, argumentsJson = '{}'): ToolCall { + return { + id, + type: 'function', + function: { name: 'lookup', arguments: argumentsJson }, + } +} + +function response( + content: string | null, + toolCalls?: ToolCall[], + reasoning?: { reasoning?: string; reasoning_content?: string } +) { + return { + choices: [{ message: { content, tool_calls: toolCalls, ...reasoning } }], + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + } +} + +async function executeStreamingRequest( + provider: ProviderConfig, + model: string +): Promise { + return provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + stream: true, + }) +} + +async function executeStreamingStructuredRequest( + provider: ProviderConfig, + model: string +): Promise { + return provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + responseFormat: { + name: 'lookup_result', + schema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + strict: true, + }, + stream: true, + }) +} + +async function readSettledEvents(result: ProviderResponse | StreamingExecution) { + if (!('stream' in result)) { + throw new Error('Expected a streaming execution') + } + + const events: unknown[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return { events, result } +} + +function expectModelIterations(result: StreamingExecution, expectedIterations: number) { + const timing = result.execution.output.providerTiming + expect(timing?.iterations).toBe(expectedIterations) + expect(timing?.timeSegments?.filter((segment) => segment.type === 'model')).toHaveLength( + expectedIterations + ) +} + +describe('settled provider tool streams', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockReset() + mockExecuteTool.mockReset() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'found' } }) + }) + + it.each(PROVIDERS)( + '$name projects the existing final answer without another provider call', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('final answer')) + + const { events, result } = await readSettledEvents( + await executeStreamingRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockCreate.mock.calls.some(([payload]) => payload.stream === true)).toBe(false) + expect(result.streamFormat).toBe('agent-events-v1') + expect(events).toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: 'final answer', + tokens: { input: 10, output: 6, total: 16 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + toolCalls: { count: 1 }, + }) + expectModelIterations(result, 2) + } + ) + + it.each(STRUCTURED_OUTPUT_PROVIDERS)( + '$name performs deferred structured extraction before projecting a settled stream', + async ({ provider, model, responseFormatType, disablesTools }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('intermediate answer')) + .mockResolvedValueOnce(response('{"value":"found"}')) + + const { events, result } = await readSettledEvents( + await executeStreamingStructuredRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(mockCreate.mock.calls[0][0].response_format).toBeUndefined() + const finalPayload = mockCreate.mock.calls[2][0] + expect(finalPayload.response_format).toMatchObject({ type: responseFormatType }) + if (disablesTools === 'none') { + expect(finalPayload.tool_choice).toBe('none') + } else { + expect(finalPayload.tools).toBeUndefined() + expect(finalPayload.tool_choice).toBeUndefined() + } + expect(events).toEqual([{ type: 'text_delta', text: '{"value":"found"}', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: '{"value":"found"}', + tokens: { input: 15, output: 9, total: 24 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + toolCalls: { count: 1 }, + }) + expectModelIterations(result, 3) + } + ) + + it.each(STRUCTURED_OUTPUT_PROVIDERS)( + '$name makes only one schema-bearing final call when the tool loop reaches its cap', + async ({ provider, model, responseFormatType }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response(null, [toolCall('call-2')])) + .mockResolvedValueOnce(response('{"value":"capped"}')) + + const { events, result } = await readSettledEvents( + await executeStreamingStructuredRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(mockCreate.mock.calls[2][0].response_format).toMatchObject({ + type: responseFormatType, + }) + expect(events).toEqual([{ type: 'text_delta', text: '{"value":"capped"}', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: '{"value":"capped"}', + tokens: { input: 15, output: 9, total: 24 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + }) + expectModelIterations(result, 3) + } + ) + + it.each(REASONING_HISTORY_PROVIDERS)( + '$name preserves the complete reasoning-bearing assistant tool turn', + async ({ provider, model, field }) => { + mockCreate + .mockResolvedValueOnce( + response('I need the lookup result.', [toolCall('call-1')], { + [field]: 'provider reasoning', + }) + ) + .mockResolvedValueOnce(response('final answer')) + + await provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + }) + + const secondPayload = mockCreate.mock.calls[1][0] as { + messages: Array> + } + const assistant = secondPayload.messages.find((message) => message.role === 'assistant') + expect(assistant).toEqual({ + role: 'assistant', + content: 'I need the lookup result.', + tool_calls: [toolCall('call-1')], + [field]: 'provider reasoning', + }) + } + ) + + it.each(PROVIDERS)( + '$name rejects tool AbortError instead of replaying it as a result', + async ({ provider, model }) => { + mockCreate.mockResolvedValueOnce(response(null, [toolCall('call-1')])) + mockExecuteTool.mockRejectedValueOnce(new DOMException('cancelled', 'AbortError')) + + await expect(executeStreamingRequest(provider, model)).rejects.toMatchObject({ + name: 'AbortError', + }) + expect(mockCreate).toHaveBeenCalledTimes(1) + } + ) + + it.each(PROVIDERS)( + '$name does not execute malformed tool arguments', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1', '{"query":')])) + .mockResolvedValueOnce(response('recovered')) + + await executeStreamingRequest(provider, model) + + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockCreate).toHaveBeenCalledTimes(2) + } + ) + + it.each(PROVIDERS)( + '$name replays a successful false output as a valid tool result', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('final answer')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: false }) + + await executeStreamingRequest(provider, model) + + const secondPayload = mockCreate.mock.calls[1][0] as { + messages: Array<{ role: string; content?: string }> + } + expect(secondPayload.messages).toContainEqual( + expect.objectContaining({ role: 'tool', content: 'false' }) + ) + } + ) + + it.each(CAPPED_PROVIDERS)( + '$name uses one tool-disabled synthesis when the iteration cap ends on a tool call', + async ({ provider, model, disablesTools }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response(null, [toolCall('call-2')])) + .mockResolvedValueOnce(response('cap synthesis')) + + const { events, result } = await readSettledEvents( + await executeStreamingRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + const finalPayload = mockCreate.mock.calls[2][0] + expect(finalPayload.stream).toBeUndefined() + if (disablesTools === 'none') { + expect(finalPayload.tool_choice).toBe('none') + } else { + expect(finalPayload.tools).toBeUndefined() + expect(finalPayload.tool_choice).toBeUndefined() + } + expect(events).toEqual([{ type: 'text_delta', text: 'cap synthesis', turn: 'final' }]) + expectModelIterations(result, 3) + } + ) +}) diff --git a/apps/sim/providers/stream-events.test.ts b/apps/sim/providers/stream-events.test.ts new file mode 100644 index 00000000000..76c93cd3373 --- /dev/null +++ b/apps/sim/providers/stream-events.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type AgentStreamEvent, + createAgentEventReadableStream, + createSettledAgentEventStream, + isAgentStreamEvent, + isTextDeltaTurn, + isToolCallEndStatus, +} from '@/providers/stream-events' + +describe('stream-events contract', () => { + describe('isAgentStreamEvent', () => { + it('accepts valid event variants', () => { + const events: AgentStreamEvent[] = [ + { type: 'text_delta', text: 'hi' }, + { type: 'text_delta', text: 'mid', turn: 'intermediate' }, + { type: 'text_delta', text: 'bye', turn: 'final' }, + { type: 'text_delta', text: 'live', turn: 'pending' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'turn_end', turn: 'final' }, + { type: 'thinking_delta', text: 'hmm' }, + { type: 'tool_call_start', id: 't1', name: 'search' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'success' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'error' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'cancelled' }, + ] + + for (const event of events) { + expect(isAgentStreamEvent(event)).toBe(true) + } + }) + + it('rejects malformed events', () => { + expect(isAgentStreamEvent(null)).toBe(false) + expect(isAgentStreamEvent({ type: 'error', message: 'x' })).toBe(false) + expect(isAgentStreamEvent({ type: 'text_delta' })).toBe(false) + expect(isAgentStreamEvent({ type: 'text_delta', text: 'x', turn: 'other' })).toBe(false) + // turn_end classifies a settled turn — 'pending' is not a valid classification. + expect(isAgentStreamEvent({ type: 'turn_end', turn: 'pending' })).toBe(false) + expect(isAgentStreamEvent({ type: 'turn_end' })).toBe(false) + expect(isAgentStreamEvent({ type: 'tool_call_start', id: 't1' })).toBe(false) + expect( + isAgentStreamEvent({ type: 'tool_call_end', id: 't1', name: 'search', status: 'ok' }) + ).toBe(false) + }) + }) + + describe('status and turn guards', () => { + it('validates tool end statuses and text turns', () => { + expect(isToolCallEndStatus('success')).toBe(true) + expect(isToolCallEndStatus('failed')).toBe(false) + expect(isTextDeltaTurn('final')).toBe(true) + expect(isTextDeltaTurn('first')).toBe(false) + }) + }) + + describe('createAgentEventReadableStream', () => { + it('enqueues events in order and closes', async () => { + const events: AgentStreamEvent[] = [ + { type: 'thinking_delta', text: 'a' }, + { type: 'text_delta', text: 'b', turn: 'final' }, + { type: 'tool_call_start', id: '1', name: 'lookup' }, + { type: 'tool_call_end', id: '1', name: 'lookup', status: 'success' }, + ] + + const stream = createAgentEventReadableStream(events) + const reader = stream.getReader() + const received: AgentStreamEvent[] = [] + + while (true) { + const { done, value } = await reader.read() + if (done) break + received.push(value) + } + + expect(received).toEqual(events) + }) + + it('errors when an invalid object is yielded', async () => { + async function* bad() { + yield { type: 'text_delta', text: 'ok' } as AgentStreamEvent + yield { type: 'nope' } as unknown as AgentStreamEvent + } + + const stream = createAgentEventReadableStream(bad()) + const reader = stream.getReader() + + await expect(reader.read()).resolves.toMatchObject({ + done: false, + value: { type: 'text_delta', text: 'ok' }, + }) + await expect(reader.read()).rejects.toThrow(/Invalid AgentStreamEvent/) + }) + }) + + it('projects a settled answer without model regeneration', async () => { + const reader = createSettledAgentEventStream('settled answer').getReader() + + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'settled answer', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/apps/sim/providers/stream-events.ts b/apps/sim/providers/stream-events.ts new file mode 100644 index 00000000000..09e460f730f --- /dev/null +++ b/apps/sim/providers/stream-events.ts @@ -0,0 +1,152 @@ +/** + * Canonical agent stream event contract (provider → executor). + * + * Providers with `streamFormat: 'agent-events-v1'` return a + * `ReadableStream` (in-process object stream — not NDJSON). + * Legacy providers keep `streamFormat: 'text'` and `ReadableStream`. + * + * The executor is the only consumer that projects these events into a text + * stream + optional sink; downstream SSE/chat must not re-parse provider streams. + */ + +export type AgentStreamFormat = 'text' | 'agent-events-v1' + +export type ToolCallEndStatus = 'success' | 'error' | 'cancelled' + +export type TextDeltaTurn = 'intermediate' | 'final' + +/** + * Classification of a `text_delta`: + * - `'final'` / omitted — answer text; the pump projects it to the byte stream + * immediately (single-turn adapters, MAX-iterations flush, legacy text streams). + * - `'intermediate'` — pre-tool commentary flushed at turn end; never projected. + * - `'pending'` — live text from a streaming tool loop whose turn is not yet + * classified. The pump buffers it per turn and projects it only when the + * matching `turn_end` arrives with `turn: 'final'`. Sinks receive it live so + * opted-in clients can render the answer as it streams. + */ +export type TextDeltaClassification = TextDeltaTurn | 'pending' + +export type AgentStreamEvent = + | { type: 'text_delta'; text: string; turn?: TextDeltaClassification } + | { + /** + * Emitted by streaming tool loops when a model turn resolves. Classifies + * every `'pending'` text_delta since the previous turn boundary: + * `'final'` keeps the text (pump projects it to the byte stream), + * `'intermediate'` discards it (tools follow; sinks should clear any + * provisionally rendered text). + */ + type: 'turn_end' + turn: TextDeltaTurn + } + | { type: 'thinking_delta'; text: string } + | { type: 'tool_call_start'; id: string; name: string } + | { + type: 'tool_call_end' + id: string + name: string + status: ToolCallEndStatus + } + +/** Optional sink the executor pump pushes the full ordered timeline into. */ +export type AgentStreamSink = { + onEvent: (event: AgentStreamEvent) => void | Promise +} + +export type UnsubscribeAgentStreamSink = () => void + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isToolCallEndStatus(value: unknown): value is ToolCallEndStatus { + return value === 'success' || value === 'error' || value === 'cancelled' +} + +export function isTextDeltaTurn(value: unknown): value is TextDeltaTurn { + return value === 'intermediate' || value === 'final' +} + +export function isTextDeltaClassification(value: unknown): value is TextDeltaClassification { + return isTextDeltaTurn(value) || value === 'pending' +} + +export function isAgentStreamEvent(value: unknown): value is AgentStreamEvent { + if (!isRecord(value) || typeof value.type !== 'string') { + return false + } + + switch (value.type) { + case 'text_delta': + return ( + typeof value.text === 'string' && + (value.turn === undefined || isTextDeltaClassification(value.turn)) + ) + case 'turn_end': + return isTextDeltaTurn(value.turn) + case 'thinking_delta': + return typeof value.text === 'string' + case 'tool_call_start': + return typeof value.id === 'string' && typeof value.name === 'string' + case 'tool_call_end': + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + isToolCallEndStatus(value.status) + ) + default: + return false + } +} + +/** + * Builds a {@link ReadableStream} that enqueues the given agent events in order. + * Intended for tests and provider adapters that already have an event sequence. + */ +export function createAgentEventReadableStream( + events: Iterable | AsyncIterable +): ReadableStream { + const iterator = + Symbol.asyncIterator in events + ? events[Symbol.asyncIterator]() + : (async function* () { + for (const event of events as Iterable) { + yield event + } + })() + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await iterator.next() + if (done) { + controller.close() + return + } + if (!isAgentStreamEvent(value)) { + controller.error(new Error('Invalid AgentStreamEvent enqueued on object stream')) + return + } + controller.enqueue(value) + } catch (error) { + controller.error(error) + } + }, + async cancel(reason) { + if (typeof iterator.return === 'function') { + await iterator.return(reason) + } + }, + }) +} + +/** + * Projects an already-settled provider answer onto the canonical stream without + * asking the model to generate it a second time. + */ +export function createSettledAgentEventStream(content: string): ReadableStream { + return createAgentEventReadableStream( + content ? [{ type: 'text_delta', text: content, turn: 'final' }] : [] + ) +} diff --git a/apps/sim/providers/stream-pump.test.ts b/apps/sim/providers/stream-pump.test.ts new file mode 100644 index 00000000000..ecd7aa64b08 --- /dev/null +++ b/apps/sim/providers/stream-pump.test.ts @@ -0,0 +1,665 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + type AgentStreamEvent, + type AgentStreamSink, + createAgentEventReadableStream, +} from '@/providers/stream-events' +import { createAgentStreamPump, DEFAULT_MAX_THINKING_CHARS } from '@/providers/stream-pump' + +async function readAllText(stream: ReadableStream | null): Promise { + if (!stream) return '' + const reader = stream.getReader() + const decoder = new TextDecoder() + let text = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + text += decoder.decode(value, { stream: true }) + } + text += decoder.decode() + return text +} + +function collectingSink() { + const events: AgentStreamEvent[] = [] + const sink: AgentStreamSink = { + onEvent: async (event) => { + events.push(event) + }, + } + return { sink, events } +} + +describe('createAgentStreamPump', () => { + it('projects agent-events to final-turn answer text and full sink timeline', async () => { + const events: AgentStreamEvent[] = [ + { type: 'thinking_delta', text: 'plan ' }, + { type: 'thinking_delta', text: 'it' }, + { type: 'text_delta', text: 'Looking up…', turn: 'intermediate' }, + { type: 'tool_call_start', id: '1', name: 'search' }, + { type: 'tool_call_end', id: '1', name: 'search', status: 'success' }, + { type: 'text_delta', text: 'Done.', turn: 'final' }, + ] + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream(events), + streamFormat: 'agent-events-v1', + }) + const { sink, events: seen } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + const text = await textPromise + + expect(result.answerText).toBe('Done.') + expect(result.fullyDrained).toBe(true) + expect(text).toBe('Done.') + expect(seen).toEqual(events) + }) + + it('buffers pending text per turn and projects it only on turn_end final', async () => { + const events: AgentStreamEvent[] = [ + { type: 'text_delta', text: 'Let me ', turn: 'pending' }, + { type: 'text_delta', text: 'check…', turn: 'pending' }, + { type: 'tool_call_start', id: '1', name: 'search' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'tool_call_end', id: '1', name: 'search', status: 'success' }, + { type: 'text_delta', text: 'Answer ', turn: 'pending' }, + { type: 'text_delta', text: 'here.', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ] + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream(events), + streamFormat: 'agent-events-v1', + }) + const { sink, events: seen } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + const text = await textPromise + + // Intermediate turn discarded; only the final turn reaches the answer. + expect(result.answerText).toBe('Answer here.') + expect(text).toBe('Answer here.') + // Sinks see the full live timeline including pending deltas + boundaries. + expect(seen).toEqual(events) + }) + + it('folds dangling pending text into answerText when the drain ends without turn_end', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'partial answer', turn: 'pending' }, + ]), + streamFormat: 'agent-events-v1', + }) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('partial answer') + expect(await textPromise).toBe('partial answer') + }) + + it('abort releases a drain blocked on byte backpressure (no deadlock)', async () => { + const controller = new AbortController() + // Enough text to exceed the byte stream's high-water mark with no reader pulling. + const bigChunk = 'x'.repeat(4096) + const source = new ReadableStream({ + start(c) { + for (let i = 0; i < 8; i++) { + c.enqueue({ type: 'text_delta', text: bigChunk, turn: 'final' }) + } + // Never closes — the only way out is the abort. + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + abortSignal: controller.signal, + }) + + // Intentionally never read pump.textStream — the consumer stalled without cancelling. + const runPromise = pump.run() + setTimeout(() => controller.abort('user'), 10) + + const result = await runPromise + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('user') + }) + + it('keeps pending text in answerText on abort so logs match what clients rendered', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'seen live', turn: 'pending' }) + queueMicrotask(() => controller.abort('user')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.answerText).toBe('seen live') + }) + + it('treats legacy text streams as final-turn answer bytes and sink text_delta', async () => { + const encoder = new TextEncoder() + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('Hel')) + controller.enqueue(encoder.encode('lo')) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'text' }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('Hello') + expect(await textPromise).toBe('Hello') + expect(events).toEqual([ + { type: 'text_delta', text: 'Hel', turn: 'final' }, + { type: 'text_delta', text: 'lo', turn: 'final' }, + ]) + }) + + it('handles UTF-8 characters split across byte chunks', async () => { + // € in UTF-8 is E2 82 AC — split across two chunks + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(0xe2, 0x82)) + controller.enqueue(Uint8Array.of(0xac, 0x20, 0x6f, 0x6b)) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'text' }) + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('€ ok') + expect(await textPromise).toBe('€ ok') + }) + + it('drops thinking when no sink is subscribed (no unbounded thinking buffer)', async () => { + const hugeThinking = 'x'.repeat(50_000) + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'thinking_delta', text: hugeThinking }, + { type: 'text_delta', text: 'hi', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('hi') + expect(await textPromise).toBe('hi') + }) + + it('caps thinking forwarded to sinks', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'thinking_delta', text: 'abcdef' }, + { type: 'thinking_delta', text: 'ghijkl' }, + { type: 'text_delta', text: 'out', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + maxThinkingChars: 4, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + await pump.run() + await textPromise + + expect(events.filter((e) => e.type === 'thinking_delta')).toEqual([ + { type: 'thinking_delta', text: 'abcd' }, + ]) + expect(DEFAULT_MAX_THINKING_CHARS).toBeGreaterThan(0) + }) + + it('allows late subscribers to receive only future events', async () => { + let pullCount = 0 + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + + const source = new ReadableStream({ + async pull(controller) { + pullCount += 1 + if (pullCount === 1) { + controller.enqueue({ type: 'thinking_delta', text: 'early' }) + await firstGate + return + } + if (pullCount === 2) { + controller.enqueue({ type: 'text_delta', text: 'late', turn: 'final' }) + return + } + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'agent-events-v1' }) + const early = collectingSink() + pump.subscribe(early.sink) + + const runPromise = pump.run() + const textPromise = readAllText(pump.textStream) + + // Wait until first event has been dispatched to early sink + await vi.waitFor(() => { + expect(early.events.length).toBe(1) + }) + + const late = collectingSink() + pump.subscribe(late.sink) + releaseFirst() + + const result = await runPromise + await textPromise + + expect(early.events.map((e) => e.type)).toEqual(['thinking_delta', 'text_delta']) + expect(late.events.map((e) => e.type)).toEqual(['text_delta']) + expect(result.answerText).toBe('late') + }) + + it('unsubscribe mid-stream detaches without failing the pump', async () => { + const seen: AgentStreamEvent[] = [] + const sink: AgentStreamSink = { + onEvent: async (event) => { + seen.push(event) + if (event.type === 'tool_call_start') { + unsubscribe() + } + }, + } + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'tool_call_start', id: '1', name: 'x' }, + { type: 'tool_call_end', id: '1', name: 'x', status: 'success' }, + { type: 'text_delta', text: 'ok', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + const unsubscribe = pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + await textPromise + + expect(result.answerText).toBe('ok') + expect(result.fullyDrained).toBe(true) + expect(seen.map((e) => e.type)).toEqual(['tool_call_start']) + }) + + it('sinkMode skips text stream and still drains', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'only-sink', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + expect(pump.textStream).toBeNull() + + const { sink, events } = collectingSink() + pump.subscribe(sink) + const result = await pump.run() + + expect(result.answerText).toBe('only-sink') + expect(events).toEqual([{ type: 'text_delta', text: 'only-sink', turn: 'final' }]) + }) + + it('awaits each sink before pulling the next event (per-event sync)', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let inFlight = 0 + let maxInFlight = 0 + + const sink: AgentStreamSink = { + onEvent: async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await gate + inFlight -= 1 + }, + } + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: '1', turn: 'final' }, + { type: 'text_delta', text: '2', turn: 'final' }, + { type: 'text_delta', text: '3', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + pump.subscribe(sink) + + const runPromise = pump.run() + await vi.waitFor(() => { + expect(maxInFlight).toBe(1) + }) + release() + await runPromise + expect(maxInFlight).toBe(1) + }) + + it('fails the pump on invalid agent-events chunks (no soft success)', async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'nope' }) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + + await expect(pump.run()).rejects.toThrow(/Invalid AgentStreamEvent/) + }) + + it('fails the pump when the provider source errors', async () => { + const source = new ReadableStream({ + start(controller) { + controller.error(new Error('provider down')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + + await expect(pump.run()).rejects.toThrow(/provider down/) + }) + + it('maps abort to cancelled result and distinguishes timeout reason', async () => { + const controller = new AbortController() + let pullCount = 0 + const source = new ReadableStream({ + async pull(ctrl) { + pullCount += 1 + if (pullCount === 1) { + ctrl.enqueue({ type: 'thinking_delta', text: '…' }) + controller.abort('timeout') + return + } + ctrl.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('timeout') + expect(result.fullyDrained).toBe(false) + }) + + it('preserves drained answerText when user abort surfaces as AbortError from reader', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'kept answer', turn: 'final' }) + c.enqueue({ type: 'thinking_delta', text: 'still thinking' }) + // Abort while the reader is waiting for more — cancel() rejects read(). + queueMicrotask(() => controller.abort('user')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('user') + expect(result.answerText).toBe('kept answer') + expect(result.fullyDrained).toBe(false) + }) + + it('does not start until run() — subscribe-before-pull', async () => { + let pulled = false + const source = new ReadableStream({ + pull(controller) { + pulled = true + controller.enqueue({ type: 'text_delta', text: 'x', turn: 'final' }) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + expect(pulled).toBe(false) + await pump.run() + expect(pulled).toBe(true) + expect(events).toHaveLength(1) + }) + + it('rejects double run()', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([{ type: 'text_delta', text: 'a', turn: 'final' }]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + await pump.run() + await expect(pump.run()).rejects.toThrow(/already started/) + }) + + it('detaches a sink that throws without failing the pump', async () => { + const bad: AgentStreamSink = { + onEvent: async () => { + throw new Error('sink exploded') + }, + } + const good = collectingSink() + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'a', turn: 'final' }, + { type: 'text_delta', text: 'b', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + pump.subscribe(bad) + pump.subscribe(good.sink) + + const result = await pump.run() + expect(result.answerText).toBe('ab') + expect(good.events.length).toBe(2) + }) + + it('synthesizes tool_call_end cancelled for open tools on abort', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'tool_call_start', id: 't1', name: 'search' }) + c.enqueue({ type: 'thinking_delta', text: 'working' }) + // Never emits tool_call_end — abort mid-flight. + setTimeout(() => controller.abort('user'), 5) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 't1', + name: 'search', + status: 'cancelled', + }) + }) + + it('synthesizes tool_call_end error for open tools on source failure', async () => { + let pulled = 0 + const source = new ReadableStream({ + pull(c) { + pulled += 1 + if (pulled === 1) { + c.enqueue({ type: 'tool_call_start', id: 't1', name: 'search' }) + return + } + c.error(new Error('provider reset')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + await expect(pump.run()).rejects.toThrow('provider reset') + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 't1', + name: 'search', + }) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 't1', + name: 'search', + status: 'error', + }) + }) +}) + +describe('projectStreamingExecutionToByteStream', () => { + it('projects agent-events final-turn text to UTF-8 bytes', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const byteStream = projectStreamingExecutionToByteStream({ + stream: createAgentEventReadableStream([ + { type: 'thinking_delta', text: 'secret' }, + { type: 'text_delta', text: 'Looking…', turn: 'intermediate' }, + { type: 'text_delta', text: 'Answer', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + expect(await readAllText(byteStream)).toBe('Answer') + }) + + it('projects pending turns classified by turn_end (live loop shape)', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const byteStream = projectStreamingExecutionToByteStream({ + stream: createAgentEventReadableStream([ + { type: 'text_delta', text: 'Preamble…', turn: 'pending' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'text_delta', text: 'Answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + expect(await readAllText(byteStream)).toBe('Answer') + }) + + it('passes through legacy text byte streams unchanged', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const encoder = new TextEncoder() + const source = new ReadableStream({ + start(c) { + c.enqueue(encoder.encode('raw')) + c.close() + }, + }) + const byteStream = projectStreamingExecutionToByteStream({ + stream: source, + streamFormat: 'text', + }) + expect(await readAllText(byteStream)).toBe('raw') + }) + + it('aborts the agent pump when the projected byte stream is cancelled', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + let sourceCancelled = false + let resolveHang!: () => void + const hang = new Promise((resolve) => { + resolveHang = resolve + }) + + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'hi', turn: 'final' }) + }, + async pull() { + // Stay open until the pump cancels the source on client disconnect. + await hang + }, + cancel() { + sourceCancelled = true + resolveHang() + }, + }) + + const byteStream = projectStreamingExecutionToByteStream({ + stream: source, + streamFormat: 'agent-events-v1', + }) + const reader = byteStream.getReader() + const first = await reader.read() + expect(first.done).toBe(false) + expect(new TextDecoder().decode(first.value)).toBe('hi') + + await reader.cancel('client disconnect') + await hang + + expect(sourceCancelled).toBe(true) + }) +}) diff --git a/apps/sim/providers/stream-pump.ts b/apps/sim/providers/stream-pump.ts new file mode 100644 index 00000000000..22d4fcf0548 --- /dev/null +++ b/apps/sim/providers/stream-pump.ts @@ -0,0 +1,505 @@ +/** + * Executor-facing agent stream pump. + * + * Owns a single drain of a provider {@link StreamingExecution} source and: + * - projects final-turn answer text to an optional legacy byte stream; + * `turn: 'pending'` deltas from streaming tool loops are buffered per turn + * and projected only when `turn_end` classifies the turn as final + * - pushes the full ordered timeline (including live pending text deltas and + * turn boundaries, so opted-in surfaces can render the answer as it streams) + * to optional sinks + * - awaits each sink per event (simple backpressure; SSE enqueues are cheap) + * + * Wired into {@link BlockExecutor} `handleStreamingExecution`. + */ + +import { + type AgentStreamEvent, + type AgentStreamFormat, + type AgentStreamSink, + isAgentStreamEvent, + type UnsubscribeAgentStreamSink, +} from '@/providers/stream-events' + +/** + * Default cap on thinking text forwarded to sinks, in UTF-16 code units. + * Enforced twice on purpose, at different scopes: the pump caps per block + * (each agent block gets its own pump) while the chat SSE `sendThinking` caps + * per execution so a many-block workflow cannot flood a public stream. + */ +export const DEFAULT_MAX_THINKING_CHARS = 512 * 1024 + +export type AgentStreamPumpCancelReason = 'user' | 'timeout' | 'unknown' + +export interface CreateAgentStreamPumpOptions { + source: ReadableStream + streamFormat: AgentStreamFormat + /** + * When true, no legacy text {@link ReadableStream} is created — upgraded + * consumers read only the sink. Avoids unbounded buffering into an unread stream. + */ + sinkMode?: boolean + maxThinkingChars?: number + abortSignal?: AbortSignal +} + +export interface AgentStreamPumpResult { + /** Final-turn answer text only (`turn: 'intermediate'` excluded). */ + answerText: string + fullyDrained: boolean + cancelled: boolean + cancelReason?: AgentStreamPumpCancelReason +} + +export interface AgentStreamPump { + subscribe: (sink: AgentStreamSink) => UnsubscribeAgentStreamSink + /** Final-turn answer bytes, or `null` when {@link CreateAgentStreamPumpOptions.sinkMode}. */ + readonly textStream: ReadableStream | null + /** + * Starts draining the provider source. Call only after the synchronous + * subscribe window (e.g. after `onStream` returns). + */ + run: () => Promise +} + +interface SinkState { + sink: AgentStreamSink + detached: boolean +} + +function resolveCancelReason(signal: AbortSignal): AgentStreamPumpCancelReason { + const reason = signal.reason + if (reason === 'timeout' || reason === 'user') { + return reason + } + if (typeof reason === 'string') { + const lower = reason.toLowerCase() + if (lower.includes('timeout')) return 'timeout' + if (lower.includes('abort') || lower.includes('cancel') || lower.includes('user')) { + return 'user' + } + } + if (reason && typeof reason === 'object') { + // Execution aborts carry DOMException('timeout' | 'user', 'AbortError'). + const { name, message } = reason as { name?: unknown; message?: unknown } + if (message === 'timeout' || String(name) === 'TimeoutError') return 'timeout' + if (message === 'user') return 'user' + } + return 'unknown' +} + +function isImmediateFinalText(event: Extract): boolean { + return event.turn === undefined || event.turn === 'final' +} + +/** + * Creates a pump over a provider stream. Subscribe synchronously before {@link AgentStreamPump.run}. + */ +export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): AgentStreamPump { + const { + source, + streamFormat, + sinkMode = false, + maxThinkingChars = DEFAULT_MAX_THINKING_CHARS, + abortSignal, + } = options + + const sinks = new Map() + let started = false + let closedTextStream = false + + let answerText = '' + let thinkingCharsForwarded = 0 + + let textController: ReadableStreamDefaultController | null = null + let textBackpressureWait: Promise | null = null + let resolveTextBackpressure: (() => void) | null = null + const textEncoder = new TextEncoder() + + const textStream = sinkMode + ? null + : new ReadableStream({ + start(controller) { + textController = controller + }, + pull() { + if (resolveTextBackpressure) { + const resolve = resolveTextBackpressure + resolveTextBackpressure = null + textBackpressureWait = null + resolve() + } + }, + cancel() { + closedTextStream = true + textController = null + if (resolveTextBackpressure) { + const resolve = resolveTextBackpressure + resolveTextBackpressure = null + textBackpressureWait = null + resolve() + } + }, + }) + + function subscribe(sink: AgentStreamSink): UnsubscribeAgentStreamSink { + if (sinks.has(sink)) { + return () => { + detachSink(sink) + } + } + + sinks.set(sink, { sink, detached: false }) + return () => detachSink(sink) + } + + function detachSink(sink: AgentStreamSink): void { + const state = sinks.get(sink) + if (!state || state.detached) return + state.detached = true + sinks.delete(sink) + } + + async function dispatchToSinks(event: AgentStreamEvent): Promise { + const active = [...sinks.values()].filter((s) => !s.detached) + if (active.length === 0) return + + await Promise.all( + active.map(async (state) => { + if (state.detached) return + try { + await state.sink.onEvent(event) + } catch { + detachSink(state.sink) + } + }) + ) + } + + async function enqueueAnswerText(text: string): Promise { + if (!text) return + + answerText += text + + if (sinkMode || closedTextStream || !textController) return + + const chunk = textEncoder.encode(text) + + while (!closedTextStream && textController) { + const desired = textController.desiredSize + if (desired === null) return + if (desired > 0) { + try { + textController.enqueue(chunk) + } catch { + closedTextStream = true + textController = null + } + return + } + if (!textBackpressureWait) { + textBackpressureWait = new Promise((resolve) => { + resolveTextBackpressure = resolve + }) + } + await textBackpressureWait + } + } + + function closeTextStream(error?: unknown): void { + if (sinkMode || closedTextStream) return + closedTextStream = true + try { + if (error !== undefined && textController) { + textController.error(error) + } else { + textController?.close() + } + } catch { + // already closed + } + textController = null + if (resolveTextBackpressure) { + resolveTextBackpressure() + resolveTextBackpressure = null + textBackpressureWait = null + } + } + + /** Open tool_call_start ids → names; settled on abort/error so sinks never hang. */ + const openTools = new Map() + + /** + * Live text from the current unclassified turn (`turn: 'pending'`). Projected + * to the byte stream only when `turn_end { turn: 'final' }` arrives; discarded + * on `turn_end { turn: 'intermediate' }` (tools follow — the text is preamble). + */ + let pendingTurnText = '' + + async function settleOpenTools(status: 'cancelled' | 'error'): Promise { + if (openTools.size === 0) return + const pending = [...openTools.entries()] + openTools.clear() + for (const [id, name] of pending) { + await dispatchToSinks({ type: 'tool_call_end', id, name, status }) + } + } + + async function handleEvent(event: AgentStreamEvent): Promise { + if (event.type === 'thinking_delta') { + const remaining = Math.max(0, maxThinkingChars - thinkingCharsForwarded) + if (remaining <= 0) return + const forwarded = event.text.length > remaining ? event.text.slice(0, remaining) : event.text + thinkingCharsForwarded += forwarded.length + if (forwarded.length > 0) { + await dispatchToSinks({ type: 'thinking_delta', text: forwarded }) + } + return + } + + if (event.type === 'text_delta') { + await dispatchToSinks(event) + if (event.turn === 'pending') { + pendingTurnText += event.text + } else if (isImmediateFinalText(event)) { + await enqueueAnswerText(event.text) + } + return + } + + if (event.type === 'turn_end') { + await dispatchToSinks(event) + const buffered = pendingTurnText + pendingTurnText = '' + if (buffered && event.turn === 'final') { + await enqueueAnswerText(buffered) + } + return + } + + if (event.type === 'tool_call_start') { + openTools.set(event.id, event.name) + await dispatchToSinks(event) + return + } + + if (event.type === 'tool_call_end') { + openTools.delete(event.id) + await dispatchToSinks(event) + return + } + + await dispatchToSinks(event) + } + + async function run(): Promise { + if (started) { + throw new Error('Agent stream pump already started') + } + started = true + + let cancelled = false + let cancelReason: AgentStreamPumpCancelReason | undefined + let fullyDrained = false + let drainError: unknown + + const reader = source.getReader() + const decoder = new TextDecoder() + + const onAbort = () => { + cancelled = true + if (abortSignal) { + cancelReason = resolveCancelReason(abortSignal) + } else { + cancelReason = 'unknown' + } + void reader.cancel(abortSignal?.reason ?? 'aborted') + // Release a drain blocked on byte backpressure — a consumer that stopped + // pulling without cancelling would otherwise deadlock the abort. + closeTextStream() + } + + if (abortSignal) { + if (abortSignal.aborted) { + onAbort() + } else { + abortSignal.addEventListener('abort', onAbort, { once: true }) + } + } + + try { + while (!cancelled) { + const { done, value } = await reader.read() + if (done) { + if (streamFormat === 'text') { + const tail = decoder.decode() + if (tail) { + await handleEvent({ type: 'text_delta', text: tail, turn: 'final' }) + } + } + fullyDrained = true + break + } + + if (streamFormat === 'text') { + if (!(value instanceof Uint8Array)) { + throw new Error('text streamFormat expected Uint8Array chunks') + } + const text = decoder.decode(value, { stream: true }) + if (text) { + await handleEvent({ type: 'text_delta', text, turn: 'final' }) + } + continue + } + + if (!isAgentStreamEvent(value)) { + throw new Error('Invalid AgentStreamEvent on agent-events-v1 stream') + } + await handleEvent(value) + } + } catch (error) { + drainError = error + } finally { + abortSignal?.removeEventListener('abort', onAbort) + try { + reader.releaseLock() + } catch { + // ignore + } + } + + // reader.cancel() / aborted read often surfaces as AbortError. That is the + // cancel path, not a hard drain failure — keep accumulated answerText. + const isAbortError = + (drainError instanceof DOMException && drainError.name === 'AbortError') || + (drainError instanceof Error && drainError.name === 'AbortError') + if (!cancelled && isAbortError && abortSignal?.aborted) { + cancelled = true + cancelReason = resolveCancelReason(abortSignal) + } + + // Synthesize tool ends — enqueue-then-error in provider loops can drop + // settlement frames (WHATWG resets the queue on error). + if (cancelled) { + await settleOpenTools('cancelled') + } else if (drainError) { + await settleOpenTools('error') + } + + /** + * Pending text without a closing `turn_end` (cancel/error mid-turn, or a + * loop that ended without classifying). Keep it in `answerText` so logs + * match what live consumers already rendered; project to the byte stream + * only on a clean drain (the stream is closing on cancel/error anyway). + */ + if (pendingTurnText) { + const dangling = pendingTurnText + pendingTurnText = '' + if (!cancelled && !drainError) { + await enqueueAnswerText(dangling) + } else { + answerText += dangling + } + } + + if (drainError && !cancelled) { + closeTextStream(drainError) + throw drainError instanceof Error ? drainError : new Error(String(drainError)) + } + + if (cancelled) { + closeTextStream() + return { + answerText, + fullyDrained: false, + cancelled: true, + cancelReason: cancelReason ?? 'unknown', + } + } + + closeTextStream() + return { + answerText, + fullyDrained, + cancelled: false, + } + } + + return { + subscribe, + textStream, + run, + } +} + +/** + * Project a {@link StreamingExecution} to a byte stream suitable for an HTTP + * `Response` body. Agent-events object streams are pumped to final-turn answer + * bytes; legacy `text` streams pass through unchanged. + * + * Cancelling the returned stream aborts the pump so provider work stops when + * the HTTP client disconnects (billing/provider reads do not continue). + */ +export function projectStreamingExecutionToByteStream(streamingExec: { + stream: ReadableStream + streamFormat?: AgentStreamFormat +}): ReadableStream { + const streamFormat = streamingExec.streamFormat ?? 'text' + if (streamFormat === 'text') { + return streamingExec.stream as ReadableStream + } + + const abortController = new AbortController() + const pump = createAgentStreamPump({ + source: streamingExec.stream, + streamFormat, + abortSignal: abortController.signal, + }) + const textStream = pump.textStream + if (!textStream) { + throw new Error('Agent stream pump expected a text projection stream') + } + + let reader: ReadableStreamDefaultReader | undefined + + return new ReadableStream({ + async start(controller) { + const runPromise = pump.run() + reader = textStream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + controller.enqueue(value) + } + await runPromise + controller.close() + } catch (error) { + if (abortController.signal.aborted) { + try { + controller.close() + } catch { + // already closed/errored + } + return + } + try { + controller.error(error instanceof Error ? error : new Error(String(error))) + } catch { + // already closed/errored + } + } finally { + try { + reader?.releaseLock() + } catch { + // ignore + } + } + }, + cancel(reason) { + abortController.abort(reason ?? 'user') + void reader?.cancel(reason).catch(() => {}) + void textStream.cancel(reason).catch(() => {}) + }, + }) +} diff --git a/apps/sim/providers/streaming-execution.test.ts b/apps/sim/providers/streaming-execution.test.ts index 25e7708b1f0..83552be2883 100644 --- a/apps/sim/providers/streaming-execution.test.ts +++ b/apps/sim/providers/streaming-execution.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' import type { NormalizedBlockOutput } from '@/executor/types' +import { createAgentEventReadableStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' /** @@ -134,9 +135,10 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) - it('only finalizes timing when the provider calls finalizeTiming', () => { + it('finalizes timing when the provider stream closes', async () => { const constructTime = 1_200 - vi.spyOn(Date, 'now').mockReturnValue(constructTime) + const drainTime = 4_500 + const nowMock = vi.spyOn(Date, 'now').mockReturnValue(constructTime) const result = createStreamingExecution({ model: 'no-finalize', @@ -154,13 +156,22 @@ describe('createStreamingExecution', () => { initialCost: { input: 0, output: 0, total: 0 }, createStream: ({ output }) => { output.content = 'no-timing-mutation' - return new ReadableStream() + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, }) + nowMock.mockReturnValue(drainTime) + await result.stream.getReader().read() + const timing = result.execution.output.providerTiming - expect(timing?.endTime).toBe(new Date(constructTime).toISOString()) - expect(timing?.duration).toBe(constructTime - providerStartTime) + expect(timing?.endTime).toBe(new Date(drainTime).toISOString()) + expect(timing?.duration).toBe(drainTime - providerStartTime) + expect(result.execution.metadata?.endTime).toBe(new Date(drainTime).toISOString()) + expect(result.execution.metadata?.duration).toBe(drainTime - providerStartTime) expect(result.execution.isStreaming).toBeUndefined() vi.restoreAllMocks() @@ -207,4 +218,71 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) + + it('propagates cancellation and finalizes timing', async () => { + const constructTime = 1_100 + const cancelTime = 3_200 + const nowMock = vi.spyOn(Date, 'now').mockReturnValue(constructTime) + const onCancel = vi.fn() + + const result = createStreamingExecution({ + model: 'cancel-model', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'cancel-model' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: () => + new ReadableStream({ + cancel: onCancel, + }), + }) + + nowMock.mockReturnValue(cancelTime) + await result.stream.cancel('consumer disconnected') + + expect(onCancel).toHaveBeenCalledWith('consumer disconnected') + expect(result.execution.output.providerTiming?.duration).toBe(cancelTime - providerStartTime) + expect(result.execution.metadata?.duration).toBe(cancelTime - providerStartTime) + + vi.restoreAllMocks() + }) + + it('defaults streamFormat to text and can attach agent-events-v1 object streams', async () => { + const textResult = createStreamingExecution({ + model: 'm', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'm' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: () => new ReadableStream(), + }) + expect(textResult.streamFormat).toBe('text') + + const events = [ + { type: 'thinking_delta' as const, text: 'reason' }, + { type: 'text_delta' as const, text: 'answer', turn: 'final' as const }, + ] + const eventResult = createStreamingExecution({ + model: 'm', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'm' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', + createStream: () => createAgentEventReadableStream(events), + }) + + expect(eventResult.streamFormat).toBe('agent-events-v1') + const reader = eventResult.stream.getReader() + const received = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + received.push(value) + } + expect(received).toEqual(events) + }) }) diff --git a/apps/sim/providers/streaming-execution.ts b/apps/sim/providers/streaming-execution.ts index 7a217af283d..9b8950090af 100644 --- a/apps/sim/providers/streaming-execution.ts +++ b/apps/sim/providers/streaming-execution.ts @@ -1,4 +1,5 @@ import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent, AgentStreamFormat } from '@/providers/stream-events' import type { TimeSegment } from '@/providers/types' /** @@ -57,7 +58,11 @@ type StreamingTiming = SimpleTiming | AccumulatedTiming interface StreamFinalizer { /** Live output object — write final `content`/`tokens`/`cost` here on drain. */ output: NormalizedBlockOutput - /** Overwrites placeholder timing from the drain timestamp. Call once on drain. */ + /** + * Overwrites placeholder timing from the drain timestamp. Providers may call + * this after populating output; the factory also guarantees finalization when + * the stream closes, errors, or is cancelled. + */ finalizeTiming: () => void } @@ -78,13 +83,21 @@ interface CreateStreamingExecutionOptions { toolCalls?: ToolCallSlice /** Marks `execution.isStreaming = true` when set. */ isStreaming?: boolean + /** + * Declares whether {@link createStream} returns UTF-8 answer bytes (`text`) + * or an in-process {@link AgentStreamEvent} object stream (`agent-events-v1`). + * Defaults to `'text'` so existing providers stay unchanged. + */ + streamFormat?: AgentStreamFormat /** * Builds the provider stream. Receives the live `output` object and a * `finalizeTiming` hook. The provider wires its native stream factory and, in * the drain callback, writes final content/tokens/cost onto `output` then * calls `finalizeTiming()`. */ - createStream: (handles: StreamFinalizer) => ReadableStream + createStream: ( + handles: StreamFinalizer + ) => ReadableStream | ReadableStream } /** @@ -104,6 +117,7 @@ export function createStreamingExecution( initialCost, toolCalls, isStreaming, + streamFormat = 'text', createStream, } = options @@ -146,29 +160,88 @@ export function createStreamingExecution( providerTiming, cost: initialCost, } + const executionMetadata = { + startTime: providerStartTimeISO, + endTime: nowISO, + duration, + } const timingKind = timing.kind - const stream = createStream({ + let timingFinalized = false + const finalizeTimingOnce = () => { + if (timingFinalized) return + timingFinalized = true + finalizeTiming(output, providerStartTime, timingKind) + const finalizedTiming = output.providerTiming + if (finalizedTiming) { + executionMetadata.endTime = finalizedTiming.endTime + executionMetadata.duration = finalizedTiming.duration + } + } + const providerStream = createStream({ output, - finalizeTiming: () => finalizeTiming(output, providerStartTime, timingKind), + finalizeTiming: finalizeTimingOnce, }) + const stream = timingFinalized + ? providerStream + : streamFormat === 'agent-events-v1' + ? withTimingFinalization( + providerStream as ReadableStream, + finalizeTimingOnce + ) + : withTimingFinalization(providerStream as ReadableStream, finalizeTimingOnce) return { stream, + streamFormat, execution: { success: true, output, logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: nowISO, - duration, - }, + metadata: executionMetadata, ...(isStreaming ? { isStreaming: true } : {}), }, } } +/** + * Finalizes provider timing exactly once when a stream settles while preserving + * backpressure and cancellation on the provider's original stream. + */ +function withTimingFinalization( + stream: ReadableStream, + finalize: () => void +): ReadableStream { + const reader = stream.getReader() + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read() + if (done) { + finalize() + reader.releaseLock() + controller.close() + return + } + controller.enqueue(value) + } catch (error) { + finalize() + reader.releaseLock() + controller.error(error) + } + }, + async cancel(reason) { + try { + await reader.cancel(reason) + } finally { + finalize() + reader.releaseLock() + } + }, + }) +} + /** * Overwrites the placeholder timing with the drain timestamp. For the simple * path the first time segment is also finalized; for the accumulated path only diff --git a/apps/sim/providers/streaming-tool-loop-shared.test.ts b/apps/sim/providers/streaming-tool-loop-shared.test.ts new file mode 100644 index 00000000000..563a0ac2d41 --- /dev/null +++ b/apps/sim/providers/streaming-tool-loop-shared.test.ts @@ -0,0 +1,26 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { parseToolArguments } from '@/providers/streaming-tool-loop-shared' + +describe('parseToolArguments', () => { + it('returns JSON objects', () => { + expect(parseToolArguments('{"query":"sim"}', 'search')).toEqual({ query: 'sim' }) + }) + + it.each(['null', '[]', '"text"', '0', 'false'])( + 'rejects non-object JSON arguments: %s', + (argumentsJson) => { + expect(() => parseToolArguments(argumentsJson, 'search')).toThrow( + 'Arguments for tool "search" must be a JSON object' + ) + } + ) + + it('rejects malformed JSON with the tool name', () => { + expect(() => parseToolArguments('{"query":', 'search')).toThrow( + 'Invalid JSON arguments for tool "search"' + ) + }) +}) diff --git a/apps/sim/providers/streaming-tool-loop-shared.ts b/apps/sim/providers/streaming-tool-loop-shared.ts new file mode 100644 index 00000000000..5ac0c49d777 --- /dev/null +++ b/apps/sim/providers/streaming-tool-loop-shared.ts @@ -0,0 +1,128 @@ +/** + * Shared plumbing for the per-provider live streaming tool loops + * (`providers/{anthropic,openai-compat,gemini,bedrock}/streaming-tool-loop.ts`). + * + * The wire handling in each loop is provider-specific; everything here is the + * provider-agnostic contract they share. + */ + +import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type { NormalizedBlockOutput } from '@/executor/types' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' + +/** + * Providers with a live streaming tool loop wired. Generated documentation and + * capability reporting consume this set; providers select their own internal + * loop from request shape. Event exposure is controlled separately. + */ +export const STREAMING_TOOL_CALL_PROVIDERS: ReadonlySet = new Set([ + 'openai', + 'anthropic', + 'azure-anthropic', + 'groq', + 'deepseek', + 'google', + 'vertex', + 'bedrock', +]) + +/** Aggregate result reported by a streaming tool loop when its stream closes. */ +export interface StreamingToolLoopComplete { + content: string + tokens: { input: number; output: number; total: number } + cost: NormalizedBlockOutput['cost'] + toolCalls?: { list: unknown[]; count: number } + modelTime: number + toolsTime: number + firstResponseTime: number + iterations: number +} + +/** True for user/SDK abort errors raised when a run is cancelled mid-stream. */ +export function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const name = (error as { name?: string }).name + return name === 'AbortError' || name === 'APIUserAbortError' +} + +/** Parse provider-supplied tool arguments without accepting non-object JSON values. */ +export function parseToolArguments( + argumentsJson: string, + toolName: string +): Record { + let parsed: unknown + try { + parsed = JSON.parse(argumentsJson) + } catch (error) { + throw new Error(`Invalid JSON arguments for tool "${toolName}"`, { cause: error }) + } + + if (!isRecordLike(parsed)) { + throw new Error(`Arguments for tool "${toolName}" must be a JSON object`) + } + + return parsed +} + +/** + * Settle every open tool with a terminal status and clear the tracking map. + * Called when a loop aborts, errors, or drains with tools still running so no + * consumer is left with a perpetually "running" tool chip. + */ +export function settleOpenTools( + controller: ReadableStreamDefaultController, + openTools: Map, + status: ToolCallEndStatus +): void { + for (const [id, name] of openTools) { + controller.enqueue({ type: 'tool_call_end', id, name, status }) + } + openTools.clear() +} + +interface TerminateToolLoopOptions { + controller: ReadableStreamDefaultController + /** Tools that emitted `tool_call_start` without a matching end. */ + openTools: Map + /** The loop's abort controller fired — request abort or consumer cancel. */ + aborted: boolean + /** The stream consumer called `cancel()`; the controller is already closed. */ + consumerCancelled: boolean + error: unknown + /** Invoked only for a genuine failure, before the stream is errored. */ + onUnexpectedError?: (error: unknown) => void +} + +/** + * Terminates a streaming tool loop that threw, settling any still-open tools. + * + * A consumer `cancel()` leaves the controller in the *closed* state, where + * `enqueue` and `close` both throw `TypeError: Invalid state`. That state is + * indistinguishable via `desiredSize` — it reports `0` when closed and `null` + * only when errored — so loops must track the cancel explicitly and stop + * writing. Every provider loop routes its catch block through here so the + * five of them cannot drift. + */ +export function terminateToolLoop({ + controller, + openTools, + aborted, + consumerCancelled, + error, + onUnexpectedError, +}: TerminateToolLoopOptions): void { + if (consumerCancelled) { + return + } + + if (aborted) { + settleOpenTools(controller, openTools, 'cancelled') + controller.close() + return + } + + settleOpenTools(controller, openTools, 'error') + onUnexpectedError?.(error) + controller.error(toError(error)) +} diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index 9d5386331cc..40624dad957 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/together/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,12 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = (assistant: { content?: string | null; reasoning?: string } = {}) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning !== undefined ? { reasoning: assistant.reasoning } : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -217,15 +221,54 @@ describe('togetherProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Together assistant content and reasoning on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await togetherProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await togetherProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await togetherProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 34c22f5c18c..d8f67bb1435 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, @@ -167,6 +171,7 @@ export const togetherProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -262,10 +267,25 @@ export const togetherProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -283,6 +303,9 @@ export const togetherProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Together):', { error: toError(error).message, @@ -305,26 +328,21 @@ export const togetherProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -335,10 +353,12 @@ export const togetherProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -416,84 +436,61 @@ export const togetherProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'together' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'together', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'together' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -549,6 +546,45 @@ export const togetherProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -566,7 +602,7 @@ export const togetherProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -589,6 +625,10 @@ export const togetherProvider: ProviderConfig = { } logger.error('Error in Together request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/together/utils.ts b/apps/sim/providers/together/utils.ts index 22b7823f342..e187e881490 100644 --- a/apps/sim/providers/together/utils.ts +++ b/apps/sim/providers/together/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Together gates native `json_schema` per-model, so we use the broadly supported @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Together AI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Together AI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Together', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Together', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/tool-call-id.ts b/apps/sim/providers/tool-call-id.ts new file mode 100644 index 00000000000..c9cdf875e5d --- /dev/null +++ b/apps/sim/providers/tool-call-id.ts @@ -0,0 +1,27 @@ +/** + * Stable execution-local tool call ids when a provider omits them (e.g. Gemini). + * Ensures `${blockId}:${toolCallId}` cannot collide within a run. + */ + +import { randomFloat } from '@sim/utils/random' + +let localToolIdCounter = 0 + +/** Reset counter — tests only. */ +export function resetLocalToolIdCounterForTests(): void { + localToolIdCounter = 0 +} + +export function allocateExecutionLocalToolId(prefix = 'local'): string { + localToolIdCounter += 1 + const rand = randomFloat().toString(36).slice(2, 8) + return `${prefix}_${localToolIdCounter}_${rand}` +} + +/** Returns provider id if non-empty, otherwise allocates a local one. */ +export function ensureToolCallId(providerId: string | null | undefined, prefix = 'local'): string { + if (typeof providerId === 'string' && providerId.trim().length > 0) { + return providerId + } + return allocateExecutionLocalToolId(prefix) +} diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index b69517a7bb0..caa82abf61e 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -1,5 +1,9 @@ import type { BlockTokens, IterationToolCall, ProviderTimingSegment } from '@/executor/types' -import { calculateCost } from '@/providers/utils' +import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' +import { + getOpenRouterReasoningDetailText, + type OpenRouterReasoningDetail, +} from '@/providers/openrouter/reasoning' /** * Minimal structural shape shared by OpenAI Chat Completions and every @@ -15,10 +19,7 @@ interface ChatCompletionLike { tool_calls?: Array | null reasoning_content?: string | null reasoning?: string | null - reasoning_details?: Array<{ - text?: string | null - summary?: string | null - } | null> | null + reasoning_details?: OpenRouterReasoningDetail[] | null } | null finish_reason?: string | null } | null> @@ -141,8 +142,8 @@ function extractChatCompletionsReasoning( } if (Array.isArray(message.reasoning_details)) { const joined = message.reasoning_details - .map((d) => d?.text ?? d?.summary ?? '') - .filter((s): s is string => typeof s === 'string' && s.length > 0) + .map(getOpenRouterReasoningDetailText) + .filter((text) => text.length > 0) .join('\n') if (joined.length > 0) return joined } @@ -193,7 +194,17 @@ export function enrichLastModelSegmentFromChatCompletions( let derivedCost = extras?.cost if (!derivedCost && extras?.model && promptTokens != null && completionTokens != null) { - const full = calculateCost(extras.model, promptTokens, completionTokens, cacheRead > 0) + // OpenAI-compatible vendors report cached tokens as a subset of the prompt + // total, so the uncached remainder is the subtraction. + const full = priceModelUsage( + extras.model, + { + input: Math.max(0, promptTokens - cacheRead), + output: completionTokens, + cacheRead, + }, + LIST_PRICE_POLICY + ) derivedCost = { input: full.input, output: full.output, total: full.total } } diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index c02dc2ebd08..2bb1a9d31c0 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -67,7 +67,7 @@ export interface FunctionCallResponse { startTime?: string endTime?: string duration?: number - result?: Record + result?: unknown output?: Record input?: Record success?: boolean @@ -84,9 +84,14 @@ export interface ProviderResponse { content: string model: string tokens?: { + /** Tokens billed at the base input rate, excluding cache reads and writes. */ input?: number output?: number total?: number + /** Input tokens served from the provider's prompt cache. */ + cacheRead?: number + /** Input tokens written to the provider's prompt cache. */ + cacheWrite?: number } toolCalls?: FunctionCallResponse[] toolResults?: Record[] @@ -168,7 +173,13 @@ export interface ProviderRequest { chatId?: string userId?: string stream?: boolean - streamToolCalls?: boolean + /** + * Run-level agent-events opt-in. Lets providers request streamable thinking + * (e.g. OpenAI reasoning summaries, Gemini thought summaries) and lets opted-in + * consumers observe the event timeline. It does not select the internal tool + * loop and never changes answer content. + */ + agentEvents?: boolean environmentVariables?: Record workflowVariables?: Record blockData?: Record @@ -185,6 +196,14 @@ export interface ProviderRequest { reasoningEffort?: string verbosity?: string thinkingLevel?: string + /** + * Opt in to caller-placed prompt-cache breakpoints on the static prefix. + * Only meaningful for models declaring `capabilities.promptCaching`; + * `sanitizeRequest` clears it otherwise. + */ + promptCaching?: boolean + /** Stable identity of the block issuing the request, used for cache routing. */ + blockId?: string isDeployedContext?: boolean callChain?: string[] /** diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index cf5778d9db3..0b10c2bf511 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -342,6 +342,8 @@ describe('Model Capabilities', () => { expect(supportsReasoningEffort('o4-mini')).toBe(true) expect(supportsReasoningEffort('azure/gpt-5')).toBe(true) expect(supportsReasoningEffort('azure/o3')).toBe(true) + expect(supportsReasoningEffort('groq/openai/gpt-oss-120b')).toBe(true) + expect(supportsReasoningEffort('groq/openai/gpt-oss-20b')).toBe(true) }) it('should return false for models without reasoning effort capability', () => { @@ -391,12 +393,16 @@ describe('Model Capabilities', () => { expect(supportsThinking('claude-sonnet-4-5')).toBe(true) expect(supportsThinking('claude-haiku-4-5')).toBe(true) expect(supportsThinking('gemini-3-flash-preview')).toBe(true) + expect(supportsThinking('deepseek-v4-flash')).toBe(true) + expect(supportsThinking('deepseek-reasoner')).toBe(true) + expect(supportsThinking('groq/qwen/qwen3.6-27b')).toBe(true) }) it('should return false for models without thinking capability', () => { expect(supportsThinking('gpt-4o')).toBe(false) expect(supportsThinking('gpt-5')).toBe(false) expect(supportsThinking('o3')).toBe(false) + expect(supportsThinking('deepseek-chat')).toBe(false) expect(supportsThinking('deepseek-v3')).toBe(false) expect(supportsThinking('unknown-model')).toBe(false) }) @@ -467,6 +473,8 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-5') + expect(MODELS_WITH_REASONING_EFFORT).toContain('groq/openai/gpt-oss-120b') + expect(MODELS_WITH_REASONING_EFFORT).toContain('groq/openai/gpt-oss-20b') }) it('should have correct models in MODELS_WITH_VERBOSITY', () => { @@ -508,6 +516,11 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_THINKING).toContain('claude-haiku-4-5') + expect(MODELS_WITH_THINKING).toContain('deepseek-v4-flash') + expect(MODELS_WITH_THINKING).toContain('deepseek-reasoner') + expect(MODELS_WITH_THINKING).not.toContain('deepseek-chat') + expect(MODELS_WITH_THINKING).toContain('groq/qwen/qwen3.6-27b') + expect(MODELS_WITH_THINKING).not.toContain('gpt-4o') expect(MODELS_WITH_THINKING).not.toContain('gpt-5') expect(MODELS_WITH_THINKING).not.toContain('o3') @@ -692,8 +705,11 @@ describe('Max Output Tokens', () => { expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000) }) + it('should return published max for DeepSeek Reasoner', () => { + expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(384000) + }) + it('should return standard default for models without maxOutputTokens', () => { - expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096) expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ba0ab5629e8..05d60e37a12 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -2,8 +2,6 @@ import { createLogger, type Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import type OpenAI from 'openai' -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { formatCreditCost } from '@/lib/billing/credits/conversion' import { env } from '@/lib/core/config/env' @@ -34,6 +32,7 @@ import { getModelPricing as getModelPricingFromDefinitions, getModelsWithDeepResearch, getModelsWithoutMemory, + getModelsWithPromptCaching, getModelsWithReasoningEffort, getModelsWithTemperatureRange, getModelsWithTemperatureSupport, @@ -1322,6 +1321,7 @@ export const MODELS_WITH_TEMPERATURE_SUPPORT = getModelsWithTemperatureSupport() export const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() export const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() export const MODELS_WITH_THINKING = getModelsWithThinking() +export const MODELS_WITH_PROMPT_CACHING = getModelsWithPromptCaching() export const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() export const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() export const PROVIDERS_WITH_TOOL_USAGE_CONTROL = getProvidersWithToolUsageControl() @@ -1342,6 +1342,11 @@ export function supportsThinking(model: string): boolean { return MODELS_WITH_THINKING.includes(model.toLowerCase()) } +/** Whether the model accepts caller-placed prompt-cache breakpoints. */ +export function supportsPromptCaching(model: string): boolean { + return MODELS_WITH_PROMPT_CACHING.includes(model.toLowerCase()) +} + export function isDeepResearchModel(model: string): boolean { return MODELS_WITH_DEEP_RESEARCH.includes(model.toLowerCase()) } @@ -1468,63 +1473,6 @@ export function prepareToolExecution( return { toolParams, executionParams } } -/** - * Creates a ReadableStream from an OpenAI-compatible streaming response. - * This is a shared utility used by all OpenAI-compatible providers: - * OpenAI, Groq, DeepSeek, xAI, OpenRouter, Mistral, Ollama, vLLM, Azure OpenAI, Cerebras - * - * @param stream - The async iterable stream from the provider - * @param providerName - Name of the provider for logging purposes - * @param onComplete - Optional callback called when stream completes with full content and usage - * @returns A ReadableStream that can be used for streaming responses - */ -export function createOpenAICompatibleStream( - stream: AsyncIterable, - providerName: string, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - const streamLogger = createLogger(`${providerName}Utils`) - let fullContent = '' - let promptTokens = 0 - let completionTokens = 0 - let totalTokens = 0 - - return new ReadableStream({ - async start(controller) { - try { - for await (const chunk of stream) { - if (chunk.usage) { - promptTokens = chunk.usage.prompt_tokens ?? 0 - completionTokens = chunk.usage.completion_tokens ?? 0 - totalTokens = chunk.usage.total_tokens ?? 0 - } - - const content = chunk.choices?.[0]?.delta?.content || '' - if (content) { - fullContent += content - controller.enqueue(new TextEncoder().encode(content)) - } - } - - if (onComplete) { - if (promptTokens === 0 && completionTokens === 0) { - streamLogger.warn(`${providerName} stream completed without usage data`) - } - onComplete(fullContent, { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: totalTokens || promptTokens + completionTokens, - }) - } - - controller.close() - } catch (error) { - controller.error(error) - } - }, - }) -} - /** * Checks if a forced tool was used in an OpenAI-compatible response and updates tracking. * This is a shared utility used by OpenAI-compatible providers: diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index ed6fa3de8f1..342067d8022 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -75,6 +75,7 @@ vi.mock('@/stores/providers', () => ({ })) import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderToolConfig } from '@/providers/types' import { vllmProvider } from '@/providers/vllm/index' @@ -84,9 +85,13 @@ interface ToolCall { function: { name: string; arguments: string } } -function chatResponse(content: string | null, toolCalls?: ToolCall[]) { +function chatResponse( + content: string | null, + toolCalls?: ToolCall[], + reasoning?: { reasoning?: string; reasoning_content?: string } +) { return { - choices: [{ message: { content, tool_calls: toolCalls } }], + choices: [{ message: { content, tool_calls: toolCalls, ...reasoning } }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, } } @@ -110,6 +115,16 @@ const toolCall = (id: string, name: string, args = '{}'): ToolCall => ({ /** Payload passed to the Nth `chat.completions.create` call. */ const createPayload = (callIndex: number) => mockCreate.mock.calls[callIndex][0] +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + afterAll(resetEnvMock) describe('vllmProvider', () => { @@ -281,6 +296,40 @@ describe('vllmProvider', () => { expect(result.toolResults).toHaveLength(1) }) + it('replays vLLM assistant content and emitted reasoning fields on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + chatResponse('I will use the tool.', [toolCall('call_1', 'myTool', '{"x":1}')], { + reasoning: 'Current vLLM reasoning.', + reasoning_content: 'Legacy vLLM reasoning.', + }) + ) + .mockResolvedValueOnce(chatResponse('final answer')) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'use a tool' }], + tools: [makeTool('myTool')], + }) + + const assistant = createPayload(1).messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Current vLLM reasoning.', + reasoning_content: 'Legacy vLLM reasoning.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'myTool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without throwing', async () => { mockExecuteTool.mockResolvedValueOnce({ success: false, error: 'tool blew up' }) mockCreate @@ -353,19 +402,33 @@ describe('vllmProvider', () => { expect('stream' in result && 'execution' in result).toBe(true) }) - it('uses tool_choice "none" on the final streaming call after tool processing', async () => { - mockCreate.mockResolvedValueOnce(chatResponse('answer')).mockResolvedValueOnce({}) + it('projects the settled tool-loop answer without a final streaming call', async () => { + mockCreate + .mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool')])) + .mockResolvedValueOnce(chatResponse('answer')) - await vllmProvider.executeRequest({ + const result = await vllmProvider.executeRequest({ model: 'vllm/llama-3', messages: [{ role: 'user', content: 'hi' }], stream: true, tools: [makeTool('myTool')], }) - const streamingPayload = createPayload(1) - expect(streamingPayload.stream).toBe(true) - expect(streamingPayload.tool_choice).toBe('none') + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('answer') + expect(result.execution.output.tokens).toEqual({ input: 20, output: 10, total: 30 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'answer', turn: 'final' }]) }) it('throws a ProviderError carrying the vLLM error message on API failure', async () => { diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 936bf3af633..0f258b8bb22 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { env } from '@/lib/core/config/env' @@ -9,7 +10,10 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getCachedProviderClient } from '@/providers/client-cache' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -240,6 +244,7 @@ export const vllmProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { let cleanContent = content @@ -358,10 +363,25 @@ export const vllmProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -379,6 +399,9 @@ export const vllmProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -398,26 +421,21 @@ export const vllmProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -428,10 +446,12 @@ export const vllmProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -534,26 +554,58 @@ export const vllmProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'vllm' } ) + + if (currentResponse.choices[0]?.message?.tool_calls?.length) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await vllm.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (content && request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'vllm' } + ) + } } if (request.stream) { - logger.info('Using streaming for final response after tool processing') + logger.info('Projecting settled response after tool processing') const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await vllm.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -562,7 +614,7 @@ export const vllmProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -573,7 +625,8 @@ export const vllmProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -582,36 +635,13 @@ export const vllmProvider: ProviderConfig = { count: toolCalls.length, } : undefined, - createStream: ({ output }) => - createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } - - output.content = cleanContent - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -631,7 +661,7 @@ export const vllmProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -660,6 +690,10 @@ export const vllmProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/vllm/utils.ts b/apps/sim/providers/vllm/utils.ts index 2b1db5bf553..f2580ab481b 100644 --- a/apps/sim/providers/vllm/utils.ts +++ b/apps/sim/providers/vllm/utils.ts @@ -1,16 +1,23 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from a vLLM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a vLLM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromVLLMStream( vllmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(vllmStream, 'vLLM', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(vllmStream, { + providerName: 'vLLM', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 42ebeb1254c..55c6ecdaecb 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -128,6 +132,7 @@ export const xAIProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromXAIStream(streamResponse, (content, usage) => { output.content = content @@ -244,12 +249,25 @@ export const xAIProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { logger.warn('XAI Provider - Tool not found:', { toolName }) - return null + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) @@ -268,6 +286,9 @@ export const xAIProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('XAI Provider - Error processing tool call:', { error: toError(error).message, @@ -290,25 +311,21 @@ export const xAIProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -318,10 +335,12 @@ export const xAIProvider: ProviderConfig = { duration: duration, toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -449,48 +468,78 @@ export const xAIProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + pendingToolCalls, { model: request.model, provider: 'xai' } ) + + if (pendingToolCalls?.length) { + const finalPayload = request.responseFormat + ? createResponseFormatPayload( + basePayload, + allMessages, + request.responseFormat, + currentMessages + ) + : { + ...basePayload, + messages: currentMessages, + tools: preparedTools?.tools, + tool_choice: 'none', + } + const finalStartTime = Date.now() + const finalResponse = await xai.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration + + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'xai' } + ) + } } } catch (error) { logger.error('XAI Provider - Error in tool processing loop:', { error: toError(error).message, iterationCount, }) + throw error } if (request.stream) { - let finalStreamingPayload: any - - if (request.responseFormat) { - finalStreamingPayload = { - ...createResponseFormatPayload( - basePayload, - allMessages, - request.responseFormat, - currentMessages - ), - stream: true, - } - } else { - finalStreamingPayload = { - ...basePayload, - messages: currentMessages, - tool_choice: 'auto', - tools: preparedTools?.tools, - stream: true, - } - } - - const streamResponse = await xai.chat.completions.create( - finalStreamingPayload as any, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } const streamingResult = createStreamingExecution({ model: request.model, @@ -509,12 +558,7 @@ export const xAIProvider: ProviderConfig = { output: tokens.output, total: tokens.total, }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, + initialCost: finalCost, toolCalls: toolCalls.length > 0 ? { @@ -523,28 +567,14 @@ export const xAIProvider: ProviderConfig = { } : undefined, isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromXAIStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -590,6 +620,10 @@ export const xAIProvider: ProviderConfig = { hasResponseFormat: !!request.responseFormat, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/xai/utils.ts b/apps/sim/providers/xai/utils.ts index 3a2acd56eca..76a21c7505f 100644 --- a/apps/sim/providers/xai/utils.ts +++ b/apps/sim/providers/xai/utils.ts @@ -1,16 +1,23 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from an xAI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an xAI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromXAIStream( xaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(xaiStream, 'xAI', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(xaiStream, { + providerName: 'xAI', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index 779b18942ea..39358ef774a 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -1,11 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -20,7 +25,6 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, - trackForcedToolUsage, } from '@/providers/utils' import { createReadableStreamFromZaiStream } from '@/providers/zai/utils' import { executeTool } from '@/tools' @@ -155,6 +159,7 @@ export const zaiProvider: ProviderConfig = { } const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload payload.messages = withSchemaGuidance( @@ -183,35 +188,37 @@ export const zaiProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromZaiStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] let currentResponse = await zai.chat.completions.create( payload, @@ -230,7 +237,6 @@ export const zaiProvider: ProviderConfig = { const toolResults: Record[] = [] const currentMessages = [...formattedMessages] let iterationCount = 0 - let hasUsedForcedTool = false let modelTime = firstResponseTime let toolsTime = 0 @@ -244,23 +250,6 @@ export const zaiProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - originalToolChoice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - try { while (iterationCount < MAX_TOOL_ITERATIONS) { if (currentResponse.choices[0]?.message?.content) { @@ -287,7 +276,7 @@ export const zaiProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -323,6 +312,9 @@ export const zaiProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -342,26 +334,21 @@ export const zaiProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -372,10 +359,12 @@ export const zaiProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -409,48 +398,12 @@ export const zaiProvider: ProviderConfig = { messages: currentMessages, } - if ( - typeof originalToolChoice === 'object' && - hasUsedForcedTool && - forcedTools.length > 0 - ) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - nextPayload.tool_choice = { - type: 'function', - function: { name: remainingTools[0] }, - } - logger.info(`Forcing next tool: ${remainingTools[0]}`) - } else { - nextPayload.tool_choice = 'auto' - logger.info('All forced tools have been used, switching to auto tool_choice') - } - } - const nextModelStartTime = Date.now() currentResponse = await zai.chat.completions.create( nextPayload, request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - nextPayload.tool_choice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - const nextModelEndTime = Date.now() const thisModelTime = nextModelEndTime - nextModelStartTime @@ -478,103 +431,71 @@ export const zaiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'zai' } ) - } - } catch (error) { - logger.error('Error in Z.ai request:', { error }) - throw error - } - - if (request.stream) { - logger.info('Using streaming for final Z.ai response after tool processing') - - const streamingPayload: any = { - ...payload, - messages: currentMessages, - stream: true, - stream_options: { include_usage: true }, - } - streamingPayload.tools = undefined - streamingPayload.tool_choice = undefined - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - streamingPayload.messages = withSchemaGuidance( - streamingPayload.messages, - buildSchemaGuidance(request.responseFormat) - ) - } - const streamResponse = await zai.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + } + finalPayload.tools = undefined + finalPayload.tool_choice = undefined + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.messages = withSchemaGuidance( + finalPayload.messages, + buildSchemaGuidance(request.responseFormat) + ) + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await zai.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - createStream: ({ output }) => - createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'zai' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Z.ai request:', { error }) + throw error } - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred response_format after tool processing') const finalFormatStartTime = Date.now() @@ -623,6 +544,52 @@ export const zaiProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -640,7 +607,7 @@ export const zaiProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -654,6 +621,10 @@ export const zaiProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/zai/utils.ts b/apps/sim/providers/zai/utils.ts index 4b86c6b7619..28ab12b1cd0 100644 --- a/apps/sim/providers/zai/utils.ts +++ b/apps/sim/providers/zai/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Z.ai streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Z.ai streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromZaiStream( zaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(zaiStream, 'Z.ai', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(zaiStream, { + providerName: 'Z.ai', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/public/library/what-is-an-mcp-server/cover.jpg b/apps/sim/public/library/what-is-an-mcp-server/cover.jpg new file mode 100644 index 00000000000..e87140b7528 Binary files /dev/null and b/apps/sim/public/library/what-is-an-mcp-server/cover.jpg differ diff --git a/apps/sim/scripts/build-pi-daytona-snapshot.ts b/apps/sim/scripts/build-pi-daytona-snapshot.ts new file mode 100644 index 00000000000..57846b155ea --- /dev/null +++ b/apps/sim/scripts/build-pi-daytona-snapshot.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env bun + +/** + * Builds the Daytona snapshot used by Create PR and Review Code — the failover + * counterpart of `build-pi-e2b-template.ts`. + * + * Both renderers consume `pi-sandbox-packages.ts`, so the two providers cannot + * drift apart. + * + * Unlike the E2B template, which layers onto `code-interpreter-v1` (Debian + * trixie + Python 3.13 + Node 20), Daytona has no equivalent base, so this image + * reconstructs that foundation: Python for the review tools' helper script and + * Node 22 for the Pi CLI. + * + * Usage: + * DAYTONA_API_KEY=... bun run apps/sim/scripts/build-pi-daytona-snapshot.ts --name sim-pi: + * bun run apps/sim/scripts/build-pi-daytona-snapshot.ts --print + * + * Daytona rejects the latest/lts/stable tags, so the name MUST carry an explicit + * tag — CI passes the same `-` it uses for ECR images. + * + * After it builds, set the printed value in the Sim app's .env: + * DAYTONA_PI_SNAPSHOT_ID= + */ + +import { Daytona, Image } from '@daytonaio/sdk' +import { getErrorMessage } from '@sim/utils/errors' +import { + PI_APT, + PI_NODE_MAJOR, + PI_NODE_VERSION_ASSERT, + PI_NPM, +} from '@/scripts/pi-sandbox-packages' + +/** Matches E2B's base: Debian 13 (trixie) with Python 3.13 installed to /usr/local. */ +const BASE_IMAGE = 'python:3.13-slim-trixie' + +/** + * `daytona-large` sizing. 10 GB is a HARD per-sandbox disk cap — the API rejects + * anything larger ("Disk request 20GB exceeds maximum allowed per sandbox + * (10GB)"), regardless of plan tier, and raising it requires contacting Daytona. + * That is the binding constraint on how large a repo Pi can clone here. + */ +const RESOURCES = { cpu: 4, memory: 8, disk: 10 } as const + +const APT_PREFIX = 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends' + +export const piImage = Image.base(BASE_IMAGE).runCommands( + `apt-get update && ${APT_PREFIX} curl ca-certificates gnupg && rm -rf /var/lib/apt/lists/*`, + // Node 22 from NodeSource, asserted at build time so an upstream change that + // ships an older Node fails here rather than at the first agent run. + `apt-get update && curl -fsSL https://deb.nodesource.com/setup_${PI_NODE_MAJOR}.x | bash - && ${APT_PREFIX} nodejs && rm -rf /var/lib/apt/lists/* && ${PI_NODE_VERSION_ASSERT}`, + `apt-get update && ${APT_PREFIX} ${PI_APT.join(' ')} && rm -rf /var/lib/apt/lists/*`, + `npm install -g ${PI_NPM.join(' ')}`, + // The clone target. E2B's base ships a world-writable /code; Pi writes to + // /workspace (cloud-review-tools.ts:14), so create it explicitly. + 'mkdir -p /workspace' +) + +async function main() { + const args = process.argv.slice(2) + + if (args.includes('--print')) { + console.log(piImage.dockerfile) + return + } + + const nameIdx = args.indexOf('--name') + const snapshotName = nameIdx !== -1 ? args[nameIdx + 1] : process.env.DAYTONA_SNAPSHOT_NAME + if (!snapshotName) { + console.error('A snapshot name is required (--name or DAYTONA_SNAPSHOT_NAME)') + process.exit(1) + } + // Daytona resolves snapshots by exact tag; `latest` is rejected outright, and an + // untagged name would silently pin to whatever was built last. + if (!snapshotName.includes(':')) { + console.error( + `Snapshot name must include an explicit tag (got "${snapshotName}"). ` + + 'Daytona does not support latest/lts/stable.' + ) + process.exit(1) + } + if (!process.env.DAYTONA_API_KEY) { + console.error('DAYTONA_API_KEY is required') + process.exit(1) + } + + console.log(`Building Pi Daytona snapshot: ${snapshotName}\n`) + + const daytona = new Daytona({ apiKey: process.env.DAYTONA_API_KEY }) + await daytona.snapshot.create( + { name: snapshotName, image: piImage, resources: RESOURCES }, + { onLogs: (log: string) => process.stdout.write(` ${log}`) } + ) + + console.log(`\nDone. Set in .env: DAYTONA_PI_SNAPSHOT_ID=${snapshotName}`) +} + +main().catch((error: unknown) => { + console.error('Build failed:', getErrorMessage(error)) + process.exit(1) +}) diff --git a/apps/sim/scripts/build-pi-e2b-template.ts b/apps/sim/scripts/build-pi-e2b-template.ts index f4641a2b7b3..147f933dae9 100644 --- a/apps/sim/scripts/build-pi-e2b-template.ts +++ b/apps/sim/scripts/build-pi-e2b-template.ts @@ -16,29 +16,26 @@ */ import { defaultBuildLogger, Template, waitForTimeout } from '@e2b/code-interpreter' +import { + PI_APT, + PI_NODE_MAJOR, + PI_NODE_VERSION_ASSERT, + PI_NPM, +} from '@/scripts/pi-sandbox-packages' const DEFAULT_TEMPLATE_NAME = 'sim-pi' -/** Exact first-party Pi versions mirrored from bun.lock because E2B builds run npm independently. */ -const PI_PACKAGES = [ - '@earendil-works/pi-coding-agent@0.80.10', - '@earendil-works/pi-agent-core@0.80.10', - '@earendil-works/pi-ai@0.80.10', - '@earendil-works/pi-tui@0.80.10', -] as const - /** Pi 0.80 requires Node >=22.19; E2B's code-interpreter base currently ships Node 20. */ -const INSTALL_NODE_COMMAND = - 'curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs && node -e "const [major, minor] = process.versions.node.split(\'.\').map(Number); if (major < 22 || (major === 22 && minor < 19)) process.exit(1)"' +const INSTALL_NODE_COMMAND = `curl -fsSL https://deb.nodesource.com/setup_${PI_NODE_MAJOR}.x | bash - && apt-get install -y nodejs && ${PI_NODE_VERSION_ASSERT}` -/** Pi uses E2B's command and filesystem APIs, so the inherited Jupyter service is unnecessary. */ +/** Pi uses the command and filesystem APIs, so the inherited Jupyter service is unnecessary. */ const START_COMMAND = 'sleep infinity' const piTemplate = Template() .fromTemplate('code-interpreter-v1') .runCmd(INSTALL_NODE_COMMAND, { user: 'root' }) - .aptInstall(['git', 'gh', 'openssh-client', 'ca-certificates', 'ripgrep', 'fd-find']) - .npmInstall([...PI_PACKAGES], { g: true }) + .aptInstall([...PI_APT]) + .npmInstall([...PI_NPM], { g: true }) .setStartCmd(START_COMMAND, waitForTimeout(1_000)) async function main() { diff --git a/apps/sim/scripts/pi-sandbox-packages.ts b/apps/sim/scripts/pi-sandbox-packages.ts new file mode 100644 index 00000000000..4a7b29c2fe9 --- /dev/null +++ b/apps/sim/scripts/pi-sandbox-packages.ts @@ -0,0 +1,54 @@ +/** + * The single source of truth for what goes into the Pi sandbox image. + * + * Two renderers consume these lists: + * - `build-pi-e2b-template.ts` — E2B template, via the `Template()` builder DSL + * - `build-pi-daytona-snapshot.ts` — Daytona snapshot, via the `Image` builder + * + * A package added here reaches both providers. Adding one to only a single + * renderer is the drift that makes a failover fail at the worst moment. + * + * The copilot repo holds the equivalent lists for the shell and doc sandboxes + * (`copilot/scripts/sandbox/packages.ts`); the Pi image lives here because Sim + * owns the Pi block. + */ + +/** Exact first-party Pi versions mirrored from bun.lock — image builds run npm independently. */ +export const PI_NPM = [ + '@earendil-works/pi-coding-agent@0.80.10', + '@earendil-works/pi-agent-core@0.80.10', + '@earendil-works/pi-ai@0.80.10', + '@earendil-works/pi-tui@0.80.10', +] as const + +/** + * `git`/`gh`/`openssh-client` back the clone → commit → push flow. `ripgrep` is + * required, not optional: the review tools shell out to the `rg` binary by name + * (`cloud-review-tools-script.ts:146`), so a missing package breaks code search + * at runtime rather than at build time. + */ +export const PI_APT = [ + 'git', + 'gh', + 'openssh-client', + 'ca-certificates', + 'ripgrep', + 'fd-find', +] as const + +/** + * Pi 0.80 requires Node >= 22.19 — higher than the Node 20 both the E2B base and + * the other two sandbox images carry, so this image installs its own. + */ +export const PI_NODE_MAJOR = 22 + +/** Fails the build loudly if the installed Node is older than Pi supports. */ +export const PI_NODE_VERSION_ASSERT = + 'node -e "const [major, minor] = process.versions.node.split(\'.\').map(Number); if (major < 22 || (major === 22 && minor < 19)) process.exit(1)"' + +/** + * The review tools run `python3 /workspace/sim-review-tools.py` + * (`cloud-review-tools.ts:15`). E2B's `code-interpreter-v1` base ships Python, so + * only the Daytona image has to provide it explicitly. + */ +export const PI_REQUIRES_PYTHON3 = true diff --git a/apps/sim/scripts/verify-sandbox-parity.ts b/apps/sim/scripts/verify-sandbox-parity.ts new file mode 100644 index 00000000000..1820a291b8f --- /dev/null +++ b/apps/sim/scripts/verify-sandbox-parity.ts @@ -0,0 +1,198 @@ +#!/usr/bin/env bun + +/** + * Runs the real sandbox execution paths against whichever provider the + * `SANDBOX_PROVIDER` env var selects, and prints a pass/fail matrix. + * + * This is the pre-switch confidence check: run it against E2B, run it against + * Daytona, and compare. Every case exercises the shared layer end-to-end + * (`executeInSandbox` / `executeShellInSandbox`) against a live sandbox — not + * mocks — so it catches the failures unit tests cannot: a missing package, an + * expired snapshot, an image that vanished during an org move, blocked egress. + * + * Usage: + * # E2B (default) + * bun run apps/sim/scripts/verify-sandbox-parity.ts + * + * # Daytona + * SANDBOX_PROVIDER=daytona \ + * DAYTONA_SHELL_SNAPSHOT_ID=mothership-shell: \ + * DAYTONA_DOC_SNAPSHOT_ID=mothership-docs: \ + * bun run apps/sim/scripts/verify-sandbox-parity.ts + * + * Exits non-zero if any case fails, so it can be wired to a schedule later. + */ + +import { getErrorMessage } from '@sim/utils/errors' +import { CodeLanguage } from '@/lib/execution/languages' +import { executeInSandbox, executeShellInSandbox } from '@/lib/execution/remote-sandbox' + +const SIM_RESULT_PREFIX = '__SIM_RESULT__=' + +interface Case { + name: string + /** Skipped unless the doc image is configured for the active provider. */ + needsDoc?: boolean + run: () => Promise<{ ok: boolean; detail: string }> +} + +const CASES: Case[] = [ + { + name: 'python: result marker round-trip', + run: async () => { + const res = await executeInSandbox({ + code: `import json\nprint("stdout-line")\nprint("${SIM_RESULT_PREFIX}" + json.dumps({"n": 42}))`, + language: CodeLanguage.Python, + timeoutMs: 120_000, + }) + const value = (res.result as { n?: number } | null)?.n + return { + ok: value === 42 && res.stdout.includes('stdout-line') && !res.error, + detail: `result=${JSON.stringify(res.result)} stdout=${JSON.stringify(res.stdout)}`, + } + }, + }, + { + name: 'python: data-science imports (base parity)', + run: async () => { + const res = await executeInSandbox({ + code: `import numpy, pandas, requests, bs4, openpyxl\nimport json\nprint("${SIM_RESULT_PREFIX}" + json.dumps({"numpy": numpy.__version__}))`, + language: CodeLanguage.Python, + timeoutMs: 180_000, + }) + return { + ok: !res.error && Boolean((res.result as { numpy?: string } | null)?.numpy), + detail: res.error ?? `numpy=${(res.result as { numpy?: string } | null)?.numpy}`, + } + }, + }, + { + name: 'python: structured error (name/value/traceback)', + run: async () => { + const res = await executeInSandbox({ + code: 'raise ValueError("boom")', + language: CodeLanguage.Python, + timeoutMs: 120_000, + }) + return { + ok: res.error === 'ValueError: boom' && res.result === null, + detail: `error=${JSON.stringify(res.error)}`, + } + }, + }, + { + name: 'python: outbound network (egress parity)', + run: async () => { + const res = await executeInSandbox({ + code: `import json, urllib.request\ncode = urllib.request.urlopen("https://example.com", timeout=20).status\nprint("${SIM_RESULT_PREFIX}" + json.dumps({"status": code}))`, + language: CodeLanguage.Python, + timeoutMs: 120_000, + }) + const status = (res.result as { status?: number } | null)?.status + return { ok: status === 200, detail: res.error ?? `status=${status}` } + }, + }, + { + name: 'javascript: imports run under node', + run: async () => { + const res = await executeInSandbox({ + code: `const os = require('os')\nconsole.log('${SIM_RESULT_PREFIX}' + JSON.stringify({ platform: os.platform() }))`, + language: CodeLanguage.JavaScript, + timeoutMs: 120_000, + }) + const platform = (res.result as { platform?: string } | null)?.platform + return { ok: platform === 'linux', detail: res.error ?? `platform=${platform}` } + }, + }, + { + name: 'shell: env vars + user-authored marker', + run: async () => { + const res = await executeShellInSandbox({ + code: `echo "${SIM_RESULT_PREFIX}$MY_VAR"`, + envs: { MY_VAR: 'from-env' }, + timeoutMs: 120_000, + }) + return { ok: res.result === 'from-env', detail: `result=${JSON.stringify(res.result)}` } + }, + }, + { + name: 'mount: inline file in, text file out', + run: async () => { + const res = await executeInSandbox({ + code: `data = open("/tmp/in.txt").read().strip()\nopen("/tmp/out.txt", "w").write(data.upper())\nprint("${SIM_RESULT_PREFIX}null")`, + language: CodeLanguage.Python, + timeoutMs: 120_000, + sandboxFiles: [{ path: '/tmp/in.txt', content: 'mounted' }], + outputSandboxPath: '/tmp/out.txt', + }) + return { + ok: res.exportedFileContent?.trim() === 'MOUNTED', + detail: res.error ?? `exported=${JSON.stringify(res.exportedFileContent)}`, + } + }, + }, + { + name: 'doc: xlsx compile + base64 binary export', + needsDoc: true, + run: async () => { + const res = await executeInSandbox({ + code: `import openpyxl\nwb = openpyxl.Workbook(); ws = wb.active\nws["A1"] = 5; ws["A2"] = 7; ws["A3"] = "=A1+A2"\nwb.save("/tmp/out.xlsx")\nprint("${SIM_RESULT_PREFIX}null")`, + language: CodeLanguage.Python, + timeoutMs: 180_000, + sandboxKind: 'doc', + outputSandboxPath: '/tmp/out.xlsx', + }) + const b64 = res.exportedFileContent ?? '' + // xlsx is a ZIP: base64 of a PK.. header always starts UEsD. + return { + ok: b64.startsWith('UEsD') && b64.length > 1000, + detail: res.error ?? `base64Len=${b64.length} head=${b64.slice(0, 8)}`, + } + }, + }, +] + +async function main() { + const provider = process.env.SANDBOX_PROVIDER || 'e2b' + const docConfigured = + provider === 'daytona' + ? Boolean(process.env.DAYTONA_DOC_SNAPSHOT_ID) + : Boolean(process.env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) + + console.log(`\nsandbox parity — provider: ${provider}\n${'='.repeat(50)}`) + + let failed = 0 + let skipped = 0 + for (const testCase of CASES) { + if (testCase.needsDoc && !docConfigured) { + console.log(`SKIP ${testCase.name} (doc image not configured)`) + skipped++ + continue + } + const started = Date.now() + try { + const { ok, detail } = await testCase.run() + const seconds = ((Date.now() - started) / 1000).toFixed(1) + console.log(`${ok ? 'PASS' : 'FAIL'} ${testCase.name} (${seconds}s)`) + if (!ok) { + console.log(` ${detail}`) + failed++ + } + } catch (error) { + console.log(`FAIL ${testCase.name}`) + console.log(` threw: ${getErrorMessage(error)}`) + failed++ + } + } + + const passed = CASES.length - failed - skipped + console.log( + `${'='.repeat(50)}\n${provider}: ${passed} passed, ${failed} failed, ${skipped} skipped\n` + ) + if (failed > 0) process.exit(1) +} + +main().catch((error: unknown) => { + console.error('harness error:', getErrorMessage(error)) + process.exit(1) +}) diff --git a/apps/sim/stores/chat/store.ts b/apps/sim/stores/chat/store.ts index f2e2a9c1cfd..0256d11138a 100644 --- a/apps/sim/stores/chat/store.ts +++ b/apps/sim/stores/chat/store.ts @@ -232,6 +232,14 @@ export const useChatStore = create()( }) }, + setMessageContent: (messageId, content) => { + set((state) => ({ + messages: state.messages.map((message) => + message.id === messageId ? { ...message, content } : message + ), + })) + }, + finalizeMessageStream: (messageId) => { set((state) => { const newMessages = state.messages.map((message) => { diff --git a/apps/sim/stores/chat/types.ts b/apps/sim/stores/chat/types.ts index 77268699909..4ed03e7dba5 100644 --- a/apps/sim/stores/chat/types.ts +++ b/apps/sim/stores/chat/types.ts @@ -64,6 +64,8 @@ export interface ChatState { setSelectedWorkflowOutput: (workflowId: string, outputIds: string[]) => void getSelectedWorkflowOutput: (workflowId: string) => string[] appendMessageContent: (messageId: string, content: string) => void + /** Replaces the message's content (streamed-turn reconciliation). */ + setMessageContent: (messageId: string, content: string) => void finalizeMessageStream: (messageId: string) => void getConversationId: (workflowId: string) => string generateNewConversationId: (workflowId: string) => string diff --git a/apps/sim/stores/terminal/console/store.test.ts b/apps/sim/stores/terminal/console/store.test.ts index 00b2b3bb3cc..4ce6ced4a54 100644 --- a/apps/sim/stores/terminal/console/store.test.ts +++ b/apps/sim/stores/terminal/console/store.test.ts @@ -137,6 +137,35 @@ describe('terminal console store', () => { expect(entry.isRunning).toBe(false) }) + it('settles live agent stream chrome when canceling', () => { + useTerminalConsoleStore.getState().addConsole({ + workflowId: 'wf-1', + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + executionId: 'exec-1', + executionOrder: 1, + isRunning: true, + agentStreamActive: true, + agentStreamThinking: 'drafting…', + agentStreamToolCalls: [ + { + key: 'block-1:t1', + id: 't1', + name: 'http_request', + displayName: 'HTTP Request', + status: 'running', + }, + ], + }) + + useTerminalConsoleStore.getState().cancelRunningEntries('wf-1', 'exec-1') + + const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1') + expect(entry.agentStreamActive).toBe(false) + expect(entry.agentStreamToolCalls?.[0]?.status).toBe('cancelled') + }) + it('only cancels running entries for the requested execution when provided', () => { useTerminalConsoleStore.getState().addConsole({ workflowId: 'wf-1', @@ -190,5 +219,72 @@ describe('terminal console store', () => { expect(entry.isRunning).toBe(false) expect(entry.endedAt).toBeDefined() }) + + it('settles live agent stream chrome when finishing', () => { + useTerminalConsoleStore.getState().addConsole({ + workflowId: 'wf-1', + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + executionId: 'exec-1', + executionOrder: 1, + isRunning: true, + agentStreamActive: true, + agentStreamToolCalls: [ + { + key: 'block-1:t1', + id: 't1', + name: 'http_request', + displayName: 'HTTP Request', + status: 'running', + }, + ], + }) + + useTerminalConsoleStore.getState().finishRunningEntries('wf-1', 'exec-1') + + const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1') + expect(entry.agentStreamActive).toBe(false) + expect(entry.agentStreamToolCalls?.[0]?.status).toBe('success') + }) + }) + + describe('updateConsole agent stream chrome', () => { + it('settles running tools and clears agentStreamActive when a block errors', () => { + useTerminalConsoleStore.getState().addConsole({ + workflowId: 'wf-1', + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + executionId: 'exec-1', + executionOrder: 1, + isRunning: true, + agentStreamActive: true, + agentStreamThinking: 'working…', + agentStreamToolCalls: [ + { + key: 'block-1:t1', + id: 't1', + name: 'http_request', + displayName: 'HTTP Request', + status: 'running', + }, + ], + }) + + useTerminalConsoleStore.getState().updateConsole( + 'block-1', + { + isRunning: false, + success: false, + error: 'timeout', + }, + 'exec-1' + ) + + const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1') + expect(entry.agentStreamActive).toBe(false) + expect(entry.agentStreamToolCalls?.[0]?.status).toBe('error') + }) }) }) diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index a3262620c3c..c21cf67b472 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -4,6 +4,10 @@ import { generateId } from '@sim/utils/id' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { useShallow } from 'zustand/react/shallow' +import { + type AgentStreamToolTerminalStatus, + settleRunningToolCallList, +} from '@/components/agent-stream/tool-call-lifecycle' import { redactApiKeys } from '@/lib/core/security/redaction' import { sendMothershipMessage } from '@/lib/mothership/events' import { getQueryClient } from '@/app/_shell/providers/query-provider' @@ -75,6 +79,27 @@ const shouldSkipEntry = (output: any): boolean => { const getBlockExecutionKey = (blockId: string, executionId?: string): string => `${executionId ?? 'no-execution'}:${blockId}` +/** + * Clears live thinking/tool chrome and settles any still-running tool chips. + * Failures often skip stream:done, so terminal paths must settle chrome here. + */ +const settleAgentStreamChrome = ( + entry: ConsoleEntry, + status: AgentStreamToolTerminalStatus +): Pick => ({ + agentStreamActive: false, + agentStreamToolCalls: settleRunningToolCallList(entry.agentStreamToolCalls, status), +}) + +const resolveAgentStreamSettleStatus = ( + entry: ConsoleEntry, + update?: ConsoleUpdate +): AgentStreamToolTerminalStatus => { + if (update?.isCanceled === true || entry.isCanceled) return 'cancelled' + if (update?.success === false || entry.success === false) return 'error' + return 'success' +} + const matchesEntryForUpdate = ( entry: ConsoleEntry, blockId: string, @@ -612,6 +637,35 @@ export const useTerminalConsoleStore = create()( updatedEntry.childWorkflowInstanceId = update.childWorkflowInstanceId } + if (update.agentStreamThinking !== undefined) { + updatedEntry.agentStreamThinking = update.agentStreamThinking + } + + if (update.agentStreamToolCalls !== undefined) { + updatedEntry.agentStreamToolCalls = update.agentStreamToolCalls + } + + if (update.agentStreamActive !== undefined) { + updatedEntry.agentStreamActive = update.agentStreamActive + } + + // Settle live chrome whenever an entry stops running or stream activity ends. + // block:error / timeouts often skip stream:done and only flip isRunning. + const shouldSettleAgentStream = + update.isRunning === false || + update.agentStreamActive === false || + update.isCanceled === true + if (shouldSettleAgentStream) { + const settled = settleAgentStreamChrome( + updatedEntry, + resolveAgentStreamSettleStatus(updatedEntry, update) + ) + updatedEntry.agentStreamActive = settled.agentStreamActive + if (update.agentStreamToolCalls === undefined) { + updatedEntry.agentStreamToolCalls = settled.agentStreamToolCalls + } + } + nextEntries[location.index] = updatedEntry } @@ -664,6 +718,7 @@ export const useTerminalConsoleStore = create()( : entry.durationMs return { ...entry, + ...settleAgentStreamChrome(entry, 'cancelled'), isRunning: false, isCanceled: true, endedAt: now.toISOString(), @@ -696,6 +751,7 @@ export const useTerminalConsoleStore = create()( : entry.durationMs return { ...entry, + ...settleAgentStreamChrome(entry, 'success'), isRunning: false, isCanceled: false, endedAt: now.toISOString(), diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index d7a0fdadf2c..08bdafb514b 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -1,3 +1,4 @@ +import type { AgentStreamToolCall } from '@/components/agent-stream/tool-call-lifecycle' import type { ParentIteration } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' import type { SubflowType } from '@/stores/workflows/workflow/types' @@ -32,6 +33,12 @@ export interface ConsoleEntry { childWorkflowName?: string /** Per-invocation unique ID linking this workflow block to its child block events */ childWorkflowInstanceId?: string + /** Live agent thinking text (canvas stream:thinking). Not part of answer content. */ + agentStreamThinking?: string + /** Live tool chips (canvas stream:tool). Name + status only. */ + agentStreamToolCalls?: AgentStreamToolCall[] + /** True while thinking/tool live updates may still arrive for this entry. */ + agentStreamActive?: boolean } export interface ConsoleUpdate { @@ -58,6 +65,9 @@ export interface ConsoleUpdate { childWorkflowBlockId?: string childWorkflowName?: string childWorkflowInstanceId?: string + agentStreamThinking?: string + agentStreamToolCalls?: AgentStreamToolCall[] + agentStreamActive?: boolean } export interface ConsoleEntryLocation { diff --git a/apps/sim/tools/firecrawl/batch-scrape.ts b/apps/sim/tools/firecrawl/batch-scrape.ts index 933c37d22ae..d3b7df2090c 100644 --- a/apps/sim/tools/firecrawl/batch-scrape.ts +++ b/apps/sim/tools/firecrawl/batch-scrape.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { FirecrawlBatchScrapeParams, FirecrawlBatchScrapeResponse, @@ -101,33 +102,7 @@ export const batchScrapeTool: ToolConfig { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', @@ -214,7 +189,10 @@ export const batchScrapeTool: ToolConfig }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { url: 'https://api.firecrawl.dev/v2/crawl', @@ -181,7 +156,10 @@ export const crawlTool: ToolConfig result.output = { pages: crawlData.data || [], total: crawlData.total || 0, - creditsUsed: crawlData.creditsUsed || 0, + // Forwarded as-is: defaulting a missing count to 0 would look like + // a free crawl to the hosted-key pricing helper instead of the + // metering failure it is. + creditsUsed: crawlData.creditsUsed, } return result } diff --git a/apps/sim/tools/firecrawl/extract.ts b/apps/sim/tools/firecrawl/extract.ts index c51c19d1886..fd9c71e6a2d 100644 --- a/apps/sim/tools/firecrawl/extract.ts +++ b/apps/sim/tools/firecrawl/extract.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ExtractParams, ExtractResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -80,33 +81,7 @@ export const extractTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/hosting.test.ts b/apps/sim/tools/firecrawl/hosting.test.ts new file mode 100644 index 00000000000..6229f671be5 --- /dev/null +++ b/apps/sim/tools/firecrawl/hosting.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { batchScrapeTool } from '@/tools/firecrawl/batch-scrape' +import { crawlTool } from '@/tools/firecrawl/crawl' +import { extractTool } from '@/tools/firecrawl/extract' +import { FIRECRAWL_CREDIT_USD } from '@/tools/firecrawl/hosting' +import { mapTool } from '@/tools/firecrawl/map' +import { parseTool } from '@/tools/firecrawl/parse' +import { scrapeTool } from '@/tools/firecrawl/scrape' +import { searchTool } from '@/tools/firecrawl/search' + +/** The slice of a tool config this suite exercises, free of each tool's param type. */ +interface HostedTool { + hosting?: { + envKeyPrefix: string + apiKeyParam: string + byokProviderId?: string + rateLimit: { mode: string } + pricing: + | { type: 'per_request'; cost: number } + | { + type: 'custom' + getCost: ( + params: never, + output: Record + ) => number | { cost: number; metadata?: Record } + } + } +} + +const HOSTED_TOOLS: Array<[string, HostedTool]> = [ + ['scrape', scrapeTool], + ['search', searchTool], + ['crawl', crawlTool], + ['map', mapTool], + ['extract', extractTool], + ['parse', parseTool], + ['batchScrape', batchScrapeTool], +] + +function getCost(tool: HostedTool, output: Record) { + const pricing = tool.hosting?.pricing + if (pricing?.type !== 'custom') throw new Error('expected custom pricing') + return pricing.getCost(undefined as never, output) +} + +describe('firecrawl hosted-key config', () => { + it.each(HOSTED_TOOLS)('%s resolves a complete hosting config', (_name, tool) => { + expect(tool.hosting).toMatchObject({ + envKeyPrefix: 'FIRECRAWL_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'firecrawl', + rateLimit: { mode: 'per_request', requestsPerMinute: 100 }, + }) + expect(tool.hosting?.pricing.type).toBe('custom') + }) + + it('bills credits reported on the response envelope', () => { + expect(getCost(searchTool, { creditsUsed: 4 })).toEqual({ + cost: 4 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 4 }, + }) + }) + + it('bills credits reported on the returned document metadata', () => { + // Firecrawl reports per-page usage on the document rather than the + // envelope; reading only the envelope left every scrape and parse unbilled. + expect(getCost(scrapeTool, { metadata: { creditsUsed: 5 } })).toEqual({ + cost: 5 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 5 }, + }) + + expect(getCost(parseTool, { metadata: { creditsUsed: 12 } })).toEqual({ + cost: 12 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 12 }, + }) + }) + + it.each(HOSTED_TOOLS)('%s rejects a response with no usable credit count', (_name, tool) => { + expect(() => getCost(tool, {})).toThrow(/creditsUsed/) + expect(() => getCost(tool, { creditsUsed: 'many' })).toThrow(/non-numeric/) + }) +}) diff --git a/apps/sim/tools/firecrawl/hosting.ts b/apps/sim/tools/firecrawl/hosting.ts new file mode 100644 index 00000000000..fc8c5d0559b --- /dev/null +++ b/apps/sim/tools/firecrawl/hosting.ts @@ -0,0 +1,68 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** Env var prefix for Firecrawl hosted keys. */ +export const FIRECRAWL_API_KEY_PREFIX = 'FIRECRAWL_API_KEY' + +/** + * Dollar cost of a single Firecrawl credit on Sim's hosted plan. + * + * Firecrawl is subscription-priced rather than pay-as-you-go, so this is the + * effective per-credit rate of the plan the hosted keys belong to. + * + * Source: https://www.firecrawl.dev/pricing + */ +export const FIRECRAWL_CREDIT_USD = 0.001 + +/** + * Reads the credits a Firecrawl response reported. + * + * Firecrawl reports usage in two documented places: job- and search-style + * endpoints put `creditsUsed` on the response envelope, while per-page + * endpoints put it on the returned document's metadata. Both are checked so a + * tool can never be wired to the wrong one. + * + * Source: https://docs.firecrawl.dev/billing + */ +function readReportedCredits(output: Record): unknown { + const fromEnvelope = output.creditsUsed + if (fromEnvelope != null) return fromEnvelope + return (output.metadata as { creditsUsed?: unknown } | undefined)?.creditsUsed +} + +/** + * Builds the Firecrawl `hosting` config shared by every Firecrawl tool. + * + * All Firecrawl operations are usage-priced — option modifiers, page counts, + * and result bands each change the credit count — so the charge always comes + * from the credits the API reported rather than a fixed per-request rate. + */ +export function firecrawlHosting

(): ToolHostingConfig

{ + return { + envKeyPrefix: FIRECRAWL_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'firecrawl', + pricing: { + type: 'custom', + getCost: (_params, output) => { + const reported = readReportedCredits(output) + if (reported == null) { + throw new Error('Firecrawl response missing creditsUsed field') + } + + const creditsUsed = Number(reported) + if (!Number.isFinite(creditsUsed)) { + throw new Error('Firecrawl response returned a non-numeric creditsUsed field') + } + + return { + cost: creditsUsed * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed }, + } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 100, + }, + } +} diff --git a/apps/sim/tools/firecrawl/map.ts b/apps/sim/tools/firecrawl/map.ts index e5a507556e2..1fd8a6a0376 100644 --- a/apps/sim/tools/firecrawl/map.ts +++ b/apps/sim/tools/firecrawl/map.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { MapParams, MapResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -66,33 +67,7 @@ export const mapTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/parse.ts b/apps/sim/tools/firecrawl/parse.ts index 756c53b36a3..a19acebfb9c 100644 --- a/apps/sim/tools/firecrawl/parse.ts +++ b/apps/sim/tools/firecrawl/parse.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ParseParams, ParseResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -83,33 +84,7 @@ export const parseTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - const creditsUsed = (output.metadata as { creditsUsed?: number })?.creditsUsed - if (creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', @@ -168,6 +143,7 @@ export const parseTool: ToolConfig = { links: result.links ?? [], metadata: result.metadata ?? null, warning: result.warning ?? null, + creditsUsed: result.creditsUsed, }, } }, diff --git a/apps/sim/tools/firecrawl/scrape.ts b/apps/sim/tools/firecrawl/scrape.ts index bdd8ab9d6f4..8246bd770f6 100644 --- a/apps/sim/tools/firecrawl/scrape.ts +++ b/apps/sim/tools/firecrawl/scrape.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ScrapeParams, ScrapeResponse } from '@/tools/firecrawl/types' import { PAGE_METADATA_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types' import { safeAssign } from '@/tools/safe-assign' @@ -31,33 +32,7 @@ export const scrapeTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - const creditsUsed = (output.metadata as { creditsUsed?: number })?.creditsUsed - if (creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/search.ts b/apps/sim/tools/firecrawl/search.ts index 38f93955a23..b5cae84bc50 100644 --- a/apps/sim/tools/firecrawl/search.ts +++ b/apps/sim/tools/firecrawl/search.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { SearchParams, SearchResponse } from '@/tools/firecrawl/types' import { SEARCH_RESULT_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -23,33 +24,7 @@ export const searchTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/google_maps/distance_matrix.ts b/apps/sim/tools/google_maps/distance_matrix.ts index 19f981e8f4d..9b3b3f23f97 100644 --- a/apps/sim/tools/google_maps/distance_matrix.ts +++ b/apps/sim/tools/google_maps/distance_matrix.ts @@ -4,6 +4,14 @@ import type { } from '@/tools/google_maps/types' import type { ToolConfig } from '@/tools/types' +/** + * Google bills Distance Matrix per element (origins × destinations), not per + * request, at $5 per 1,000 elements. + * + * Source: https://developers.google.com/maps/billing-and-pricing/pricing#distance-matrix + */ +const DISTANCE_MATRIX_ELEMENT_USD = 0.005 + export const googleMapsDistanceMatrixTool: ToolConfig< GoogleMapsDistanceMatrixParams, GoogleMapsDistanceMatrixResponse @@ -63,8 +71,15 @@ export const googleMapsDistanceMatrixTool: ToolConfig< apiKeyParam: 'apiKey', byokProviderId: 'google_cloud', pricing: { - type: 'per_request', - cost: 0.005, + type: 'custom', + getCost: (_params, output) => { + const rows = (output.rows as Array<{ elements?: unknown[] }> | undefined) ?? [] + const elements = rows.reduce((total, row) => total + (row.elements?.length ?? 0), 0) + return { + cost: elements * DISTANCE_MATRIX_ELEMENT_USD, + metadata: { elements }, + } + }, }, rateLimit: { mode: 'per_request', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index c4fb4775447..62af30c8e1a 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -580,24 +580,37 @@ interface ToolCostResult { metadata?: Record } +/** + * Rejects a cost that cannot be billed. `NaN` would silently vanish from every + * downstream sum and `Infinity` would poison the ledger, so a pricing bug must + * surface as a metering failure instead of a corrupt charge. + */ +function assertBillableCost(cost: unknown, toolId: string): number { + if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0) { + throw new Error(`Hosted-key pricing for ${toolId} produced an unusable cost: ${String(cost)}`) + } + return cost +} + /** * Calculate cost based on pricing model */ function calculateToolCost( pricing: ToolHostingPricing, params: Record, - response: Record + response: Record, + toolId: string ): ToolCostResult { switch (pricing.type) { case 'per_request': - return { cost: pricing.cost } + return { cost: assertBillableCost(pricing.cost, toolId) } case 'custom': { const result = pricing.getCost(params, response) if (typeof result === 'number') { - return { cost: result } + return { cost: assertBillableCost(result, toolId) } } - return result + return { ...result, cost: assertBillableCost(result.cost, toolId) } } default: { @@ -627,7 +640,7 @@ async function processHostedKeyCost( return { cost: 0 } } - const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response) + const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response, tool.id) if (cost <= 0) return { cost: 0 } @@ -730,16 +743,31 @@ async function applyHostedKeyCostToResult( ): Promise { await reportCustomDimensionUsage(tool, params, finalResult.output, executionContext, requestId) - const { cost: hostedKeyCost, metadata } = await processHostedKeyCost( - tool, - params, - finalResult.output, - executionContext, - requestId - ) - const provider = tool.hosting?.byokProviderId || tool.id const key = envVarName ?? 'unknown' + + let hostedKeyCost = 0 + let metadata: Record | undefined + + try { + ;({ cost: hostedKeyCost, metadata } = await processHostedKeyCost( + tool, + params, + finalResult.output, + executionContext, + requestId + )) + } catch (error) { + // The provider already ran and already charged Sim's key. Failing the + // execution here would destroy the caller's result without recovering the + // spend, so the run stands and the gap is raised for reconciliation. + logger.error( + `[${requestId}] Hosted-key metering failed for ${tool.id}; execution succeeded unbilled`, + { provider, error: getErrorMessage(error) } + ) + hostedKeyMetrics.recordFailed({ provider, tool: tool.id, key, reason: 'metering' }) + } + hostedKeyMetrics.recordUsed({ provider, tool: tool.id, key }) hostedKeyMetrics.recordCostCharged(hostedKeyCost, { provider, tool: tool.id }) diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index a1d9b3f76a4..e06daade15f 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { type ModelCost, resolveProxiedModelCost } from '@/providers/cost-policy' import { getProviderFromModel } from '@/providers/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' @@ -34,6 +35,7 @@ interface LLMChatResponse extends ToolResponse { completion?: number total?: number } + cost?: ModelCost } } @@ -175,6 +177,10 @@ export const llmChatTool: ToolConfig = { content: data.content, model: data.model, tokens: data.tokens, + // The provider proxy already applied the billing policy. Dropping its + // cost here would leave blocks built on this tool reporting tokens + // with no charge. + cost: resolveProxiedModelCost(data.cost), }, } }, @@ -183,5 +189,6 @@ export const llmChatTool: ToolConfig = { content: { type: 'string', description: 'The generated response content' }, model: { type: 'string', description: 'The model used for generation' }, tokens: { type: 'object', description: 'Token usage information' }, + cost: { type: 'object', description: 'Model cost for this call in dollars' }, }, } diff --git a/apps/sim/tools/parallel/deep_research.ts b/apps/sim/tools/parallel/deep_research.ts index cd7dac4b9eb..628d72a0c2b 100644 --- a/apps/sim/tools/parallel/deep_research.ts +++ b/apps/sim/tools/parallel/deep_research.ts @@ -6,6 +6,42 @@ import type { ToolConfig, ToolResponse } from '@/tools/types' const logger = createLogger('ParallelDeepResearchTool') +/** + * Dollar cost of one Parallel Task run per processor tier. + * + * Fast variants are priced identically to their standard counterparts, so both + * spellings are listed — the block exposes `pro-fast` and `ultra-fast`, and an + * unlisted tier would silently fall back to the cheapest rate. + * + * Source: https://docs.parallel.ai/getting-started/pricing + */ +const PROCESSOR_COST_USD: Record = { + lite: 0.005, + 'lite-fast': 0.005, + base: 0.01, + 'base-fast': 0.01, + core: 0.025, + 'core-fast': 0.025, + core2x: 0.05, + 'core2x-fast': 0.05, + pro: 0.1, + 'pro-fast': 0.1, + ultra: 0.3, + 'ultra-fast': 0.3, + ultra2x: 0.6, + 'ultra2x-fast': 0.6, + ultra4x: 1.2, + 'ultra4x-fast': 1.2, + ultra8x: 2.4, + 'ultra8x-fast': 2.4, +} + +/** + * Tier used when the caller does not pick one. Shared by the request body and + * the cost calculation so the tier Sim asks for is always the tier it charges. + */ +const DEFAULT_PROCESSOR = 'pro' + export const deepResearchTool: ToolConfig = { id: 'parallel_deep_research', name: 'Parallel AI Deep Research', @@ -20,34 +56,21 @@ export const deepResearchTool: ToolConfig { - // Parallel Task API: cost varies by processor - // https://docs.parallel.ai/resources/pricing - const processorCosts: Record = { - lite: 0.005, - base: 0.01, - core: 0.025, - core2x: 0.05, - pro: 0.1, - ultra: 0.3, - ultra2x: 0.6, - ultra4x: 1.2, - ultra8x: 2.4, - } - const processor = (params.processor as string) || 'base' - const DEFAULT_PROCESSOR_COST = processorCosts.base - const knownCost = processorCosts[processor] + const processor = (params.processor as string) || DEFAULT_PROCESSOR + const fallbackCost = PROCESSOR_COST_USD[DEFAULT_PROCESSOR] + const knownCost = PROCESSOR_COST_USD[processor] if (knownCost == null) { logger.warn( - `Unknown Parallel processor "${processor}", using default processor cost $${DEFAULT_PROCESSOR_COST}` + `Unknown Parallel processor "${processor}", using default processor cost $${fallbackCost}` ) PlatformEvents.hostedKeyUnknownModelCost({ toolId: 'parallel_deep_research', modelName: processor, - defaultCost: DEFAULT_PROCESSOR_COST, + defaultCost: fallbackCost, }) } - const cost = knownCost ?? DEFAULT_PROCESSOR_COST - return { cost, metadata: { processor, defaultProcessorCost: DEFAULT_PROCESSOR_COST } } + const cost = knownCost ?? fallbackCost + return { cost, metadata: { processor, defaultProcessorCost: fallbackCost } } }, }, rateLimit: { @@ -67,7 +90,7 @@ export const deepResearchTool: ToolConfig { const body: Record = { input: params.input, - processor: params.processor || 'pro', + processor: params.processor || DEFAULT_PROCESSOR, task_spec: { output_schema: 'auto', }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 296a2bae42d..9dc641b5cdb 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -4530,12 +4530,14 @@ import { webflowUpdateItemTool, } from '@/tools/webflow' import { + whatsappGetMediaTool, whatsappMarkReadTool, whatsappSendInteractiveTool, whatsappSendMediaTool, whatsappSendMessageTool, whatsappSendReactionTool, whatsappSendTemplateTool, + whatsappUploadMediaTool, } from '@/tools/whatsapp' import { wikipediaPageContentTool, @@ -5928,6 +5930,8 @@ export const tools: Record = { whatsapp_send_interactive: whatsappSendInteractiveTool, whatsapp_send_reaction: whatsappSendReactionTool, whatsapp_mark_read: whatsappMarkReadTool, + whatsapp_upload_media: whatsappUploadMediaTool, + whatsapp_get_media: whatsappGetMediaTool, x_write: xWriteTool, x_read: xReadTool, x_search: xSearchTool, diff --git a/apps/sim/tools/whatsapp/get_media.ts b/apps/sim/tools/whatsapp/get_media.ts new file mode 100644 index 00000000000..506a77439c2 --- /dev/null +++ b/apps/sim/tools/whatsapp/get_media.ts @@ -0,0 +1,77 @@ +import type { ToolConfig } from '@/tools/types' +import type { WhatsAppGetMediaParams, WhatsAppGetMediaResponse } from '@/tools/whatsapp/types' + +function getExecutionContext(params: WhatsAppGetMediaParams): { + workspaceId?: string + workflowId?: string + executionId?: string +} { + const context = params._context + return { + workspaceId: typeof context?.workspaceId === 'string' ? context.workspaceId : undefined, + workflowId: typeof context?.workflowId === 'string' ? context.workflowId : undefined, + executionId: typeof context?.executionId === 'string' ? context.executionId : undefined, + } +} + +export const getMediaTool: ToolConfig = { + id: 'whatsapp_get_media', + name: 'WhatsApp Download Media', + description: + 'Download media a customer sent you. Takes the media ID from an incoming WhatsApp message and stores the file in the workflow.', + version: '1.0.0', + + params: { + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Media asset ID from an incoming message, e.g. . This is not the message ID (wamid).', + }, + phoneNumberId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business Phone Number ID (from Meta Business Suite)', + }, + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business API Access Token (from Meta Developer Portal)', + }, + }, + + request: { + url: '/api/tools/whatsapp/get-media', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + mediaId: params.mediaId, + phoneNumberId: params.phoneNumberId, + ...getExecutionContext(params), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to download WhatsApp media') + } + return { success: true, output: data.output } + }, + + outputs: { + file: { type: 'file', description: 'Downloaded media stored as a workflow file' }, + mediaId: { type: 'string', description: 'WhatsApp media ID that was downloaded' }, + mimeType: { type: 'string', description: 'MIME type reported by WhatsApp' }, + fileSize: { type: 'number', description: 'Size of the downloaded media in bytes' }, + sha256: { + type: 'string', + description: 'SHA-256 hash WhatsApp reported for the media, for integrity checks', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/whatsapp/index.ts b/apps/sim/tools/whatsapp/index.ts index 50316221728..1fbc540a85c 100644 --- a/apps/sim/tools/whatsapp/index.ts +++ b/apps/sim/tools/whatsapp/index.ts @@ -1,3 +1,4 @@ +export { getMediaTool as whatsappGetMediaTool } from '@/tools/whatsapp/get_media' export { markReadTool as whatsappMarkReadTool } from '@/tools/whatsapp/mark_read' export { sendInteractiveTool as whatsappSendInteractiveTool } from '@/tools/whatsapp/send_interactive' export { sendMediaTool as whatsappSendMediaTool } from '@/tools/whatsapp/send_media' @@ -5,3 +6,4 @@ export { sendMessageTool as whatsappSendMessageTool } from '@/tools/whatsapp/sen export { sendReactionTool as whatsappSendReactionTool } from '@/tools/whatsapp/send_reaction' export { sendTemplateTool as whatsappSendTemplateTool } from '@/tools/whatsapp/send_template' export * from '@/tools/whatsapp/types' +export { uploadMediaTool as whatsappUploadMediaTool } from '@/tools/whatsapp/upload_media' diff --git a/apps/sim/tools/whatsapp/mark_read.ts b/apps/sim/tools/whatsapp/mark_read.ts index 585580ec47f..d4bcd23cc1c 100644 --- a/apps/sim/tools/whatsapp/mark_read.ts +++ b/apps/sim/tools/whatsapp/mark_read.ts @@ -1,11 +1,17 @@ import type { ToolConfig } from '@/tools/types' import type { WhatsAppMarkReadParams, WhatsAppMarkReadResponse } from '@/tools/whatsapp/types' -import { buildAuthHeaders, buildMessagesUrl, isRecord } from '@/tools/whatsapp/utils' +import { + buildAuthHeaders, + buildMessagesUrl, + extractWhatsAppErrorMessage, + parseWhatsAppResponse, +} from '@/tools/whatsapp/utils' export const markReadTool: ToolConfig = { id: 'whatsapp_mark_read', name: 'WhatsApp Mark As Read', - description: 'Mark a received WhatsApp message as read so the sender sees blue checkmarks.', + description: + 'Mark a received WhatsApp message as read so the sender sees blue checkmarks, optionally showing a typing indicator.', version: '1.0.0', params: { @@ -15,6 +21,13 @@ export const markReadTool: ToolConfig = { messaging_product: 'whatsapp', status: 'read', message_id: params.messageId.trim(), } + if (params.showTypingIndicator) { + body.typing_indicator = { type: 'text' } + } + return body }, }, transformResponse: async (response: Response) => { - const responseText = await response.text() - const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} - const data = isRecord(parsed) ? parsed : {} - const error = isRecord(data.error) ? data.error : undefined + const data = await parseWhatsAppResponse(response) if (!response.ok) { - const errorMessage = - (typeof error?.message === 'string' ? error.message : undefined) || - (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || - `WhatsApp API error (${response.status})` - throw new Error(errorMessage) + throw new Error(extractWhatsAppErrorMessage(data, response.status)) } return { diff --git a/apps/sim/tools/whatsapp/send_interactive.ts b/apps/sim/tools/whatsapp/send_interactive.ts index 16d2e76aa8b..5da33c438f9 100644 --- a/apps/sim/tools/whatsapp/send_interactive.ts +++ b/apps/sim/tools/whatsapp/send_interactive.ts @@ -34,39 +34,41 @@ export const sendInteractiveTool: ToolConfig = new Set(['image', 'video', 'document']) +import type { WhatsAppSendMediaParams, WhatsAppSendResponse } from '@/tools/whatsapp/types' +import { whatsappSendOutputs } from '@/tools/whatsapp/utils' export const sendMediaTool: ToolConfig = { id: 'whatsapp_send_media', name: 'WhatsApp Send Media', description: - 'Send an image, document, video, or audio message via a public link or an uploaded media ID.', + 'Send an image, document, video, audio, or sticker message. Accepts an uploaded file, a media ID, or a public link.', version: '1.0.0', params: { @@ -33,25 +20,33 @@ export const sendMediaTool: ToolConfig buildMessagesUrl(params.phoneNumberId), + url: '/api/tools/whatsapp/send-media', method: 'POST', - headers: (params) => buildAuthHeaders(params.accessToken), - body: (params) => { - if (!params.phoneNumber) { - throw new Error('Phone number is required but was not provided') - } - - const mediaType = params.mediaType?.trim() as WhatsAppMediaType - if (!MEDIA_TYPES.includes(mediaType)) { - throw new Error(`Media type must be one of: ${MEDIA_TYPES.join(', ')}`) - } - - const link = params.mediaLink?.trim() - const id = params.mediaId?.trim() - if (!link && !id) { - throw new Error('Either mediaLink or mediaId is required') - } - - const media: Record = id ? { id } : { link: link as string } - if (params.caption && CAPTION_TYPES.has(mediaType)) { - media.caption = params.caption - } - if (params.filename && mediaType === 'document') { - media.filename = params.filename - } - - return { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: params.phoneNumber.trim(), - type: mediaType, - [mediaType]: media, - } - }, + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + phoneNumberId: params.phoneNumberId, + phoneNumber: params.phoneNumber, + mediaType: params.mediaType, + file: params.file, + mediaId: params.mediaId, + mediaLink: params.mediaLink, + caption: params.caption, + filename: params.filename, + }), }, - transformResponse: transformWhatsAppSendResponse, + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to send WhatsApp media') + } + return { success: true, output: data.output } + }, outputs: whatsappSendOutputs, } diff --git a/apps/sim/tools/whatsapp/send_message.ts b/apps/sim/tools/whatsapp/send_message.ts index fa8b3aabe29..b96125ba122 100644 --- a/apps/sim/tools/whatsapp/send_message.ts +++ b/apps/sim/tools/whatsapp/send_message.ts @@ -1,14 +1,17 @@ import type { ToolConfig } from '@/tools/types' import type { WhatsAppResponse, WhatsAppSendMessageParams } from '@/tools/whatsapp/types' - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} +import { + buildAuthHeaders, + buildMessagesUrl, + transformWhatsAppSendResponse, + whatsappSendOutputs, +} from '@/tools/whatsapp/utils' export const sendMessageTool: ToolConfig = { id: 'whatsapp_send_message', name: 'WhatsApp Send Message', - description: 'Send a text message through the WhatsApp Cloud API.', + description: + 'Send a free-form text message through the WhatsApp Cloud API. Only works inside the 24-hour customer service window — use Send Template to start a conversation.', version: '1.0.0', params: { @@ -22,7 +25,7 @@ export const sendMessageTool: ToolConfig { - if (!params.phoneNumberId) { - throw new Error('WhatsApp Phone Number ID is required') - } - return `https://graph.facebook.com/v25.0/${params.phoneNumberId.trim()}/messages` - }, + url: (params) => buildMessagesUrl(params.phoneNumberId), method: 'POST', - headers: (params) => { - if (!params.accessToken) { - throw new Error('WhatsApp Access Token is required') - } - return { - Authorization: `Bearer ${params.accessToken.trim()}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => buildAuthHeaders(params.accessToken), body: (params) => { if (!params.phoneNumber) { throw new Error('Phone number is required but was not provided') @@ -89,92 +79,7 @@ export const sendMessageTool: ToolConfig { - const responseText = await response.text() - const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} - const data = isRecord(parsed) ? parsed : {} - const error = isRecord(data.error) ? data.error : undefined - - if (!response.ok) { - const errorMessage = - (typeof error?.message === 'string' ? error.message : undefined) || - (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || - (isRecord(error?.error_data) && typeof error.error_data.details === 'string' - ? error.error_data.details - : undefined) || - `WhatsApp API error (${response.status})` - throw new Error(errorMessage) - } + transformResponse: transformWhatsAppSendResponse, - const contacts = Array.isArray(data.contacts) - ? data.contacts.filter(isRecord).map((contact) => ({ - input: typeof contact.input === 'string' ? contact.input : '', - wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : null, - })) - : [] - const firstMessage = - Array.isArray(data.messages) && isRecord(data.messages[0]) ? data.messages[0] : undefined - const messageId = typeof firstMessage?.id === 'string' ? firstMessage.id : undefined - const messageStatus = - typeof firstMessage?.message_status === 'string' ? firstMessage.message_status : undefined - - if (!messageId) { - throw new Error('WhatsApp API response did not include a message ID') - } - - return { - success: true, - output: { - success: true, - messageId, - messageStatus, - messagingProduct: - typeof data.messaging_product === 'string' ? data.messaging_product : undefined, - inputPhoneNumber: contacts[0]?.input ?? null, - whatsappUserId: contacts[0]?.wa_id ?? null, - contacts, - }, - } - }, - - outputs: { - success: { type: 'boolean', description: 'WhatsApp message send success status' }, - messageId: { type: 'string', description: 'Unique WhatsApp message identifier' }, - messageStatus: { - type: 'string', - description: 'Initial delivery state returned by the API', - optional: true, - }, - messagingProduct: { - type: 'string', - description: 'Messaging product returned by the API', - optional: true, - }, - inputPhoneNumber: { - type: 'string', - description: 'Recipient phone number echoed back by WhatsApp', - optional: true, - }, - whatsappUserId: { - type: 'string', - description: 'WhatsApp user ID resolved for the recipient', - optional: true, - }, - contacts: { - type: 'array', - description: 'Recipient contact records returned by WhatsApp', - optional: true, - items: { - type: 'object', - properties: { - input: { type: 'string', description: 'Input phone number sent to the API' }, - wa_id: { - type: 'string', - description: 'WhatsApp user ID associated with the recipient', - optional: true, - }, - }, - }, - }, - }, + outputs: whatsappSendOutputs, } diff --git a/apps/sim/tools/whatsapp/send_reaction.ts b/apps/sim/tools/whatsapp/send_reaction.ts index 4664ab69d3c..65b87876742 100644 --- a/apps/sim/tools/whatsapp/send_reaction.ts +++ b/apps/sim/tools/whatsapp/send_reaction.ts @@ -25,7 +25,8 @@ export const sendReactionTool: ToolConfig = { + id: 'whatsapp_upload_media', + name: 'WhatsApp Upload Media', + description: + 'Upload a file to WhatsApp and get a media ID for sending. Uploaded media is retained for 30 days and can back many sends.', + version: '1.0.0', + + params: { + file: { + type: 'file', + required: true, + visibility: 'user-only', + description: + 'File to upload. WhatsApp limits: images 5 MB, video and audio 16 MB, documents 100 MB, stickers 500 KB.', + }, + phoneNumberId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business Phone Number ID (from Meta Business Suite)', + }, + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business API Access Token (from Meta Developer Portal)', + }, + }, + + request: { + url: '/api/tools/whatsapp/upload-media', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + phoneNumberId: params.phoneNumberId, + file: params.file, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to upload media to WhatsApp') + } + return { success: true, output: data.output } + }, + + outputs: { + mediaId: { + type: 'string', + description: 'WhatsApp media ID. Pass this to Send Media to attach the uploaded file.', + }, + fileName: { type: 'string', description: 'Name of the uploaded file' }, + mimeType: { type: 'string', description: 'MIME type WhatsApp received the file as' }, + size: { type: 'number', description: 'Size of the uploaded file in bytes' }, + }, +} diff --git a/apps/sim/tools/whatsapp/utils.ts b/apps/sim/tools/whatsapp/utils.ts index 1a5b6c509f0..8b176422ebe 100644 --- a/apps/sim/tools/whatsapp/utils.ts +++ b/apps/sim/tools/whatsapp/utils.ts @@ -1,4 +1,4 @@ -import type { WhatsAppSendResponse } from '@/tools/whatsapp/types' +import type { WhatsAppMediaType, WhatsAppSendResponse } from '@/tools/whatsapp/types' /** WhatsApp Cloud API Graph version used by every outbound tool. */ export const WHATSAPP_GRAPH_VERSION = 'v25.0' @@ -11,6 +11,49 @@ export function buildMessagesUrl(phoneNumberId: string | undefined): string { return `https://graph.facebook.com/${WHATSAPP_GRAPH_VERSION}/${phoneNumberId.trim()}/messages` } +/** Build the media upload endpoint for a given business phone number ID. */ +export function buildMediaUploadUrl(phoneNumberId: string): string { + return `https://graph.facebook.com/${WHATSAPP_GRAPH_VERSION}/${encodeURIComponent(phoneNumberId.trim())}/media` +} + +/** Build the media metadata endpoint for a media ID, optionally scoped to a phone number. */ +export function buildMediaUrl(mediaId: string, phoneNumberId?: string): string { + const base = `https://graph.facebook.com/${WHATSAPP_GRAPH_VERSION}/${encodeURIComponent(mediaId.trim())}` + return phoneNumberId + ? `${base}?phone_number_id=${encodeURIComponent(phoneNumberId.trim())}` + : base +} + +/** + * Per-type upload ceilings documented in the WhatsApp Cloud API media reference. + * Enforced before any bytes leave Sim so oversized files fail with an actionable + * message instead of WhatsApp's generic error 131052. + */ +const WHATSAPP_MEDIA_LIMITS = [ + { prefix: 'image/', maxBytes: 5 * 1024 * 1024, label: 'Images (5 MB)' }, + { prefix: 'video/', maxBytes: 16 * 1024 * 1024, label: 'Videos (16 MB)' }, + { prefix: 'audio/', maxBytes: 16 * 1024 * 1024, label: 'Audio (16 MB)' }, +] as const + +/** Stickers are `image/webp` but carry a far tighter cap than other images. */ +const WHATSAPP_STICKER_MAX_BYTES = 500 * 1024 + +/** Documents carry the largest documented ceiling, so it doubles as the overall cap. */ +export const WHATSAPP_MEDIA_MAX_BYTES = 100 * 1024 * 1024 + +/** + * Resolve the documented upload ceiling for a MIME type. Unknown types fall back to + * the 100 MB document ceiling, which is also the largest WhatsApp accepts. + */ +export function whatsappMediaLimitFor(mimeType: string): { maxBytes: number; label: string } { + const normalized = mimeType.toLowerCase() + if (normalized === 'image/webp') { + return { maxBytes: WHATSAPP_STICKER_MAX_BYTES, label: 'Stickers (500 KB)' } + } + const match = WHATSAPP_MEDIA_LIMITS.find((limit) => normalized.startsWith(limit.prefix)) + return match ?? { maxBytes: WHATSAPP_MEDIA_MAX_BYTES, label: 'Documents (100 MB)' } +} + /** Build the shared Bearer auth headers for the WhatsApp Cloud API. */ export function buildAuthHeaders(accessToken: string | undefined): Record { if (!accessToken) { @@ -26,23 +69,91 @@ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -async function parseWhatsAppResponse(response: Response): Promise> { +export async function parseWhatsAppResponse(response: Response): Promise> { const responseText = await response.text() const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} return isRecord(parsed) ? parsed : {} } -/** Extract a human-readable error message from a WhatsApp API error payload. */ -function extractErrorMessage(data: Record, status: number): string { +/** + * Extract a human-readable error message from a WhatsApp API error payload. + * + * The Cloud API error envelope is `{ error: { message, type, code, error_subcode, + * error_data: { details }, fbtrace_id } }`. `error_data.details` usually carries the + * actionable explanation while `message` carries the summary, so both are surfaced, + * along with the numeric `code` that the error-code reference is keyed on. + */ +export function extractWhatsAppErrorMessage(data: Record, status: number): string { const error = isRecord(data.error) ? data.error : undefined - return ( - (typeof error?.message === 'string' ? error.message : undefined) || - (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || - (isRecord(error?.error_data) && typeof error.error_data.details === 'string' + const summary = typeof error?.message === 'string' ? error.message : undefined + const details = + isRecord(error?.error_data) && typeof error.error_data.details === 'string' ? error.error_data.details - : undefined) || - `WhatsApp API error (${status})` - ) + : undefined + const code = typeof error?.code === 'number' ? error.code : undefined + + const base = [summary, details].filter(Boolean).join(' — ') || `WhatsApp API error (${status})` + return code === undefined || base.includes(`${code}`) ? base : `${base} (code ${code})` +} + +export const WHATSAPP_MEDIA_TYPES = [ + 'image', + 'document', + 'video', + 'audio', + 'sticker', +] as const satisfies readonly WhatsAppMediaType[] + +/** Audio and sticker messages have no `caption` field; only documents accept `filename`. */ +const CAPTION_TYPES: ReadonlySet = new Set(['image', 'video', 'document']) + +interface MediaMessageInput { + phoneNumber?: string + mediaType?: string + mediaId?: string + mediaLink?: string + caption?: string + filename?: string +} + +/** + * Build the `/messages` body for a media send. Exactly one of `mediaId` or `mediaLink` + * must be set — the Cloud API treats `id` and `link` as mutually exclusive. + */ +export function buildMediaMessageBody(params: MediaMessageInput): Record { + if (!params.phoneNumber) { + throw new Error('Phone number is required but was not provided') + } + + const mediaType = params.mediaType?.trim() as WhatsAppMediaType + if (!WHATSAPP_MEDIA_TYPES.includes(mediaType)) { + throw new Error(`Media type must be one of: ${WHATSAPP_MEDIA_TYPES.join(', ')}`) + } + + const link = params.mediaLink?.trim() + const id = params.mediaId?.trim() + if (!link && !id) { + throw new Error('Either a file, a media ID, or a media link is required') + } + if (link && id) { + throw new Error('Provide either a media ID or a media link, not both') + } + + const media: Record = id ? { id } : { link: link as string } + if (params.caption && CAPTION_TYPES.has(mediaType)) { + media.caption = params.caption + } + if (params.filename && mediaType === 'document') { + media.filename = params.filename + } + + return { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: params.phoneNumber.trim(), + type: mediaType, + [mediaType]: media, + } } /** @@ -55,7 +166,7 @@ export async function transformWhatsAppSendResponse( const data = await parseWhatsAppResponse(response) if (!response.ok) { - throw new Error(extractErrorMessage(data, response.status)) + throw new Error(extractWhatsAppErrorMessage(data, response.status)) } const contacts = Array.isArray(data.contacts) @@ -98,7 +209,8 @@ export const whatsappSendOutputs = { messageId: { type: 'string', description: 'Unique WhatsApp message identifier' }, messageStatus: { type: 'string', - description: 'Initial delivery state returned by the API', + description: + 'Message pacing status when WhatsApp returns one: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — subscribe to the webhook trigger for delivery status.', optional: true, }, messagingProduct: { diff --git a/apps/sim/tools/whatsapp/whatsapp.test.ts b/apps/sim/tools/whatsapp/whatsapp.test.ts new file mode 100644 index 00000000000..da03761887b --- /dev/null +++ b/apps/sim/tools/whatsapp/whatsapp.test.ts @@ -0,0 +1,535 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { WhatsAppBlock } from '@/blocks/blocks/whatsapp' +import { getMediaTool } from '@/tools/whatsapp/get_media' +import { markReadTool } from '@/tools/whatsapp/mark_read' +import { sendInteractiveTool } from '@/tools/whatsapp/send_interactive' +import { sendMediaTool } from '@/tools/whatsapp/send_media' +import { sendMessageTool } from '@/tools/whatsapp/send_message' +import { sendReactionTool } from '@/tools/whatsapp/send_reaction' +import { sendTemplateTool } from '@/tools/whatsapp/send_template' +import { uploadMediaTool } from '@/tools/whatsapp/upload_media' +import { + buildMediaMessageBody, + buildMediaUploadUrl, + buildMediaUrl, + transformWhatsAppSendResponse, + whatsappMediaLimitFor, +} from '@/tools/whatsapp/utils' + +const auth = { phoneNumberId: ' 15550001111 ', accessToken: ' token ' } + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), init) +} + +describe('WhatsApp request URL and headers', () => { + it('trims the phone number ID into the versioned messages endpoint', () => { + expect(sendMessageTool.request.url(auth)).toBe( + 'https://graph.facebook.com/v25.0/15550001111/messages' + ) + }) + + it('trims the access token into a Bearer header', () => { + expect(sendMessageTool.request.headers!(auth)).toEqual({ + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }) + }) + + it('throws when the phone number ID is missing', () => { + expect(() => sendMessageTool.request.url({ ...auth, phoneNumberId: '' })).toThrow( + /Phone Number ID is required/ + ) + }) +}) + +describe('sendMessageTool body', () => { + const base = { ...auth, phoneNumber: ' +14155552671 ', message: 'hi' } + + it('sends a trimmed recipient and omits preview_url when unset', () => { + expect(sendMessageTool.request.body!(base)).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'text', + text: { body: 'hi' }, + }) + }) + + it('includes preview_url only when an explicit boolean is provided', () => { + expect(sendMessageTool.request.body!({ ...base, previewUrl: true })).toMatchObject({ + text: { body: 'hi', preview_url: true }, + }) + expect(sendMessageTool.request.body!({ ...base, previewUrl: false })).toMatchObject({ + text: { body: 'hi', preview_url: false }, + }) + }) +}) + +describe('sendTemplateTool body', () => { + const base = { ...auth, phoneNumber: '+14155552671', templateName: ' hello ', languageCode: 'en' } + + it('omits components when none are provided', () => { + expect(sendTemplateTool.request.body!(base)).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'template', + template: { name: 'hello', language: { code: 'en' } }, + }) + }) + + it('parses a JSON string of components', () => { + const components = [{ type: 'body', parameters: [{ type: 'text', text: 'x' }] }] + expect( + sendTemplateTool.request.body!({ ...base, components: JSON.stringify(components) }) + ).toMatchObject({ template: { components } }) + }) + + it('rejects components that are not an array', () => { + expect(() => + sendTemplateTool.request.body!({ ...base, components: '{"type":"body"}' }) + ).toThrow(/must be a JSON array/) + }) +}) + +describe('buildMediaMessageBody', () => { + const base = { phoneNumber: '+14155552671' } + + it('sends a link under a key named after the media type', () => { + expect( + buildMediaMessageBody({ ...base, mediaType: 'image', mediaLink: ' https://x/a.png ' }) + ).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'image', + image: { link: 'https://x/a.png' }, + }) + }) + + it('rejects an unsupported media type', () => { + expect(() => + buildMediaMessageBody({ ...base, mediaType: 'gif', mediaLink: 'https://x/a.gif' }) + ).toThrow(/Media type must be one of/) + }) + + it('supports stickers, which take no caption', () => { + const body = buildMediaMessageBody({ + ...base, + mediaType: 'sticker', + mediaId: '123', + caption: 'ignored', + }) + + expect(body).toMatchObject({ type: 'sticker' }) + expect(body.sticker).toEqual({ id: '123' }) + }) + + it('requires exactly one media source', () => { + expect(() => buildMediaMessageBody({ ...base, mediaType: 'image' })).toThrow( + /a file, a media ID, or a media link is required/ + ) + expect(() => + buildMediaMessageBody({ + ...base, + mediaType: 'image', + mediaLink: 'https://x/a.png', + mediaId: '123', + }) + ).toThrow(/not both/) + }) + + it('drops caption for audio and filename for non-documents', () => { + const audioBody = buildMediaMessageBody({ + ...base, + mediaType: 'audio', + mediaId: '1', + caption: 'nope', + filename: 'nope.mp3', + }) + expect(audioBody.audio).toEqual({ id: '1' }) + + const imageBody = buildMediaMessageBody({ + ...base, + mediaType: 'image', + mediaId: '1', + caption: 'shown', + filename: 'nope.png', + }) + expect(imageBody.image).toEqual({ id: '1', caption: 'shown' }) + + expect( + buildMediaMessageBody({ + ...base, + mediaType: 'document', + mediaId: '1', + caption: 'report', + filename: 'q3.pdf', + }) + ).toMatchObject({ document: { id: '1', caption: 'report', filename: 'q3.pdf' } }) + }) +}) + +describe('sendMediaTool', () => { + it('routes through the internal route so a file can be uploaded before sending', () => { + expect(sendMediaTool.request.url).toBe('/api/tools/whatsapp/send-media') + }) + + it('forwards every media source to the route', () => { + const file = { name: 'a.png', key: 'k', url: 'u', size: 1, type: 'image/png' } + expect( + sendMediaTool.request.body!({ ...auth, phoneNumber: '+1', mediaType: 'image', file }) + ).toMatchObject({ file, mediaType: 'image', phoneNumber: '+1' }) + }) +}) + +describe('sendInteractiveTool body', () => { + const base = { ...auth, phoneNumber: '+14155552671', bodyText: 'Pick one' } + const buttons = [{ type: 'reply', reply: { id: 'yes', title: 'Yes' } }] + const sections = [{ title: 'Menu', rows: [{ id: 'r1', title: 'Row 1' }] }] + + it('builds a button message with optional header and footer', () => { + expect( + sendInteractiveTool.request.body!({ + ...base, + buttons, + headerText: 'Header', + footerText: 'Footer', + }) + ).toMatchObject({ + type: 'interactive', + interactive: { + type: 'button', + body: { text: 'Pick one' }, + header: { type: 'text', text: 'Header' }, + footer: { text: 'Footer' }, + action: { buttons }, + }, + }) + }) + + it('builds a list message from sections and a menu button label', () => { + expect( + sendInteractiveTool.request.body!({ ...base, sections, listButtonText: ' Menu ' }) + ).toMatchObject({ + interactive: { type: 'list', action: { button: 'Menu', sections } }, + }) + }) + + it('requires a menu button label for lists', () => { + expect(() => sendInteractiveTool.request.body!({ ...base, sections })).toThrow( + /listButtonText is required/ + ) + }) + + it('requires exactly one of buttons and sections', () => { + expect(() => sendInteractiveTool.request.body!(base)).toThrow(/either buttons .* or sections/) + expect(() => + sendInteractiveTool.request.body!({ ...base, buttons, sections, listButtonText: 'Menu' }) + ).toThrow(/not both/) + }) + + it('treats an empty array as absent', () => { + expect(() => sendInteractiveTool.request.body!({ ...base, buttons: [] })).toThrow( + /either buttons .* or sections/ + ) + }) +}) + +describe('sendReactionTool body', () => { + const base = { ...auth, phoneNumber: '+14155552671', messageId: ' wamid.abc ' } + + it('trims the target message ID and sends the emoji', () => { + expect(sendReactionTool.request.body!({ ...base, emoji: '👍' })).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'reaction', + reaction: { message_id: 'wamid.abc', emoji: '👍' }, + }) + }) + + it('sends an empty emoji to remove a reaction', () => { + expect(sendReactionTool.request.body!(base)).toMatchObject({ + reaction: { message_id: 'wamid.abc', emoji: '' }, + }) + }) +}) + +describe('markReadTool body', () => { + const base = { ...auth, messageId: ' wamid.abc ' } + + it('omits the typing indicator by default', () => { + expect(markReadTool.request.body!(base)).toEqual({ + messaging_product: 'whatsapp', + status: 'read', + message_id: 'wamid.abc', + }) + }) + + it('adds a text typing indicator when requested', () => { + expect(markReadTool.request.body!({ ...base, showTypingIndicator: true })).toMatchObject({ + typing_indicator: { type: 'text' }, + }) + }) + + it('reports success from the {"success": true} response', async () => { + const result = await markReadTool.transformResponse!(jsonResponse({ success: true })) + expect(result.output.success).toBe(true) + }) + + it('surfaces the API error message and code', async () => { + await expect( + markReadTool.transformResponse!( + jsonResponse( + { error: { message: 'Invalid parameter', code: 100, error_data: { details: 'bad id' } } }, + { status: 400 } + ) + ) + ).rejects.toThrow('Invalid parameter — bad id (code 100)') + }) +}) + +describe('WhatsApp media endpoints', () => { + it('builds the upload endpoint from a trimmed phone number ID', () => { + expect(buildMediaUploadUrl(' 15550001111 ')).toBe( + 'https://graph.facebook.com/v25.0/15550001111/media' + ) + }) + + it('builds the media metadata endpoint with and without the scoping param', () => { + expect(buildMediaUrl(' 987654321 ')).toBe('https://graph.facebook.com/v25.0/987654321') + expect(buildMediaUrl('987654321', '15550001111')).toBe( + 'https://graph.facebook.com/v25.0/987654321?phone_number_id=15550001111' + ) + }) + + it('encodes IDs so a crafted value cannot alter the path', () => { + expect(buildMediaUrl('../messages')).toBe('https://graph.facebook.com/v25.0/..%2Fmessages') + }) +}) + +describe('whatsappMediaLimitFor', () => { + it('applies the documented per-type ceilings', () => { + expect(whatsappMediaLimitFor('image/jpeg').maxBytes).toBe(5 * 1024 * 1024) + expect(whatsappMediaLimitFor('video/mp4').maxBytes).toBe(16 * 1024 * 1024) + expect(whatsappMediaLimitFor('audio/ogg').maxBytes).toBe(16 * 1024 * 1024) + expect(whatsappMediaLimitFor('application/pdf').maxBytes).toBe(100 * 1024 * 1024) + }) + + it('gives stickers the tighter cap even though they are images', () => { + expect(whatsappMediaLimitFor('image/webp').maxBytes).toBe(500 * 1024) + expect(whatsappMediaLimitFor('IMAGE/WEBP').maxBytes).toBe(500 * 1024) + }) + + it('falls back to the document ceiling for unknown types', () => { + expect(whatsappMediaLimitFor('application/x-thing').maxBytes).toBe(100 * 1024 * 1024) + }) +}) + +describe('WhatsAppBlock file param mapping', () => { + const userFile = { + id: 'f1', + name: 'invoice.pdf', + url: 'https://storage/invoice.pdf', + size: 1024, + type: 'application/pdf', + key: 'execution/ws/wf/ex/invoice.pdf', + } + + function mapParams(params: Record) { + return WhatsAppBlock.tools.config!.params!(params) as Record + } + + /** + * The serializer deletes the `uploadFile`/`uploadFileRef` subblock IDs and republishes + * the value under the `file` canonicalParamId, so the params mapper must read the + * canonical key. Reading a subblock ID here would silently upload nothing. + */ + it('maps the canonical file param onto the tool file param', () => { + expect(mapParams({ operation: 'upload_media', file: userFile }).file).toEqual(userFile) + }) + + it('parses a JSON-stringified file reference from advanced mode', () => { + expect(mapParams({ operation: 'upload_media', file: JSON.stringify(userFile) }).file).toEqual( + userFile + ) + }) + + it('unwraps a single-element array so a file reference resolves to one file', () => { + expect(mapParams({ operation: 'upload_media', file: [userFile] }).file).toEqual(userFile) + }) + + it('omits file entirely when nothing was uploaded', () => { + expect(mapParams({ operation: 'upload_media' })).not.toHaveProperty('file') + }) + + it('does not leak the UI-only params to the tool', () => { + const mapped = mapParams({ + operation: 'upload_media', + file: userFile, + interactiveType: 'button', + downloadMediaId: 'should-not-leak', + }) + + expect(mapped).not.toHaveProperty('interactiveType') + expect(mapped).not.toHaveProperty('downloadMediaId') + }) + + it('maps the download media ID onto the tool mediaId param', () => { + expect(mapParams({ operation: 'get_media', downloadMediaId: '123' }).mediaId).toBe('123') + }) + + describe('send_media media source', () => { + /** The canonical pair carries exactly one thing — a file. Upload basic, reference advanced. */ + it('pairs a file upload with a file reference and nothing else', () => { + const members = WhatsAppBlock.subBlocks.filter( + (sub) => sub.canonicalParamId === 'mediaSource' + ) + + expect(members.map((sub) => [sub.id, sub.type, sub.mode])).toEqual([ + ['sendMediaFile', 'file-upload', 'basic'], + ['sendMediaFileRef', 'short-input', 'advanced'], + ]) + }) + + it('resolves either pair member to the tool file param', () => { + expect(mapParams({ operation: 'send_media', mediaSource: userFile }).file).toEqual(userFile) + expect( + mapParams({ operation: 'send_media', mediaSource: JSON.stringify(userFile) }).file + ).toEqual(userFile) + }) + + /** + * Media ID and media link are separate concepts, so they stay separate subblocks and pass + * through untouched. Keeping them out of the pair is also what preserves workflows built + * before the file upload existed. + */ + it('passes a media ID or link through without touching the file param', () => { + const byId = mapParams({ operation: 'send_media', mediaId: '1003383421387256' }) + expect(byId.mediaId).toBe('1003383421387256') + expect(byId.file).toBeUndefined() + + const byLink = mapParams({ operation: 'send_media', mediaLink: 'https://legacy/a.png' }) + expect(byLink.mediaLink).toBe('https://legacy/a.png') + expect(byLink.file).toBeUndefined() + }) + + /** None of the three sources is statically required, so no path fails pre-execution validation. */ + it('leaves every media source optional so any one of them can satisfy the send', () => { + const sourceIds = ['sendMediaFile', 'sendMediaFileRef', 'mediaId', 'mediaLink'] + for (const id of sourceIds) { + const sub = WhatsAppBlock.subBlocks.find((candidate) => candidate.id === id) + expect(sub?.required, `${id} must not be required`).toBe(false) + } + expect(mapParams({ operation: 'send_media' })).not.toHaveProperty('file') + }) + }) +}) + +describe('media tool request bodies', () => { + it('forwards the file and credentials to the upload route', () => { + const file = { name: 'a.pdf', key: 'k', url: 'u', size: 10, type: 'application/pdf' } + expect(uploadMediaTool.request.url).toBe('/api/tools/whatsapp/upload-media') + expect(uploadMediaTool.request.body!({ ...auth, file })).toEqual({ + accessToken: ' token ', + phoneNumberId: ' 15550001111 ', + file, + }) + }) + + it('forwards execution context so the download is stored as an execution file', () => { + const body = getMediaTool.request.body!({ + ...auth, + mediaId: '123', + _context: { workspaceId: 'ws', workflowId: 'wf', executionId: 'ex', userId: 'u' }, + }) + + expect(body).toEqual({ + accessToken: ' token ', + phoneNumberId: ' 15550001111 ', + mediaId: '123', + workspaceId: 'ws', + workflowId: 'wf', + executionId: 'ex', + }) + }) + + it('omits execution context when the tool runs outside an execution', () => { + expect(getMediaTool.request.body!({ ...auth, mediaId: '123' })).toEqual({ + accessToken: ' token ', + phoneNumberId: ' 15550001111 ', + mediaId: '123', + workspaceId: undefined, + workflowId: undefined, + executionId: undefined, + }) + }) + + it('surfaces the route error message', async () => { + await expect( + getMediaTool.transformResponse!(jsonResponse({ success: false, error: 'Media not found' })) + ).rejects.toThrow('Media not found') + }) +}) + +describe('transformWhatsAppSendResponse', () => { + it('extracts the message ID, pacing status, and first contact', async () => { + const result = await transformWhatsAppSendResponse( + jsonResponse({ + messaging_product: 'whatsapp', + contacts: [{ input: '+14155552671', wa_id: '14155552671' }], + messages: [{ id: 'wamid.abc', message_status: 'accepted' }], + }) + ) + + expect(result.output).toEqual({ + success: true, + messageId: 'wamid.abc', + messageStatus: 'accepted', + messagingProduct: 'whatsapp', + inputPhoneNumber: '+14155552671', + whatsappUserId: '14155552671', + contacts: [{ input: '+14155552671', wa_id: '14155552671' }], + }) + }) + + it('tolerates a reaction response that omits message_status', async () => { + const result = await transformWhatsAppSendResponse( + jsonResponse({ + messaging_product: 'whatsapp', + contacts: [{ input: '+14155552671', wa_id: '14155552671' }], + messages: [{ id: 'wamid.abc' }], + }) + ) + + expect(result.output.messageId).toBe('wamid.abc') + expect(result.output.messageStatus).toBeUndefined() + }) + + it('nulls out contact fields when WhatsApp returns no contacts', async () => { + const result = await transformWhatsAppSendResponse( + jsonResponse({ messaging_product: 'whatsapp', messages: [{ id: 'wamid.abc' }] }) + ) + + expect(result.output.inputPhoneNumber).toBeNull() + expect(result.output.whatsappUserId).toBeNull() + expect(result.output.contacts).toEqual([]) + }) + + it('throws when the response carries no message ID', async () => { + await expect( + transformWhatsAppSendResponse(jsonResponse({ messaging_product: 'whatsapp', messages: [] })) + ).rejects.toThrow(/did not include a message ID/) + }) + + it('falls back to the status code when the error body has no message', async () => { + await expect(transformWhatsAppSendResponse(jsonResponse({}, { status: 503 }))).rejects.toThrow( + 'WhatsApp API error (503)' + ) + }) +}) diff --git a/apps/sim/triggers/whatsapp/webhook.ts b/apps/sim/triggers/whatsapp/webhook.ts index 8de96d84962..e5d0728d882 100644 --- a/apps/sim/triggers/whatsapp/webhook.ts +++ b/apps/sim/triggers/whatsapp/webhook.ts @@ -1,5 +1,5 @@ import { WhatsAppIcon } from '@/components/icons' -import type { TriggerConfig } from '../types' +import type { TriggerConfig } from '@/triggers/types' export const whatsappWebhookTrigger: TriggerConfig = { id: 'whatsapp_webhook', @@ -105,6 +105,19 @@ export const whatsappWebhookTrigger: TriggerConfig = { type: 'string', description: 'Type of the first incoming message in the batch (text, image, system, etc.)', }, + mediaId: { + type: 'string', + description: + 'Media asset ID from the first incoming media message. Pass to the Download Media operation to fetch the file. Expires after 7 days.', + }, + mediaMimeType: { + type: 'string', + description: 'MIME type of the first incoming media message', + }, + caption: { + type: 'string', + description: 'Caption on the first incoming image, video, or document message', + }, status: { type: 'string', description: diff --git a/bun.lock b/bun.lock index dcce09861c5..945b4faf210 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,12 @@ "turbo": "2.9.14", "yaml": "^2.8.1", }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.11", + "@next/swc-darwin-x64": "16.2.11", + "@next/swc-linux-arm64-gnu": "16.2.11", + "@next/swc-linux-x64-gnu": "16.2.11", + }, }, "apps/docs": { "name": "docs", @@ -48,7 +54,7 @@ "shiki": "4.3.1", "streamdown": "2.5.0", "tailwind-merge": "^3.0.2", - "zod": "^4.3.6", + "zod": "4.3.6", }, "devDependencies": { "@sim/tsconfig": "workspace:*", @@ -104,7 +110,7 @@ "dependencies": { "@1password/sdk": "0.3.1", "@a2a-js/sdk": "1.0.0-alpha.0", - "@anthropic-ai/sdk": "0.71.2", + "@anthropic-ai/sdk": "0.114.0", "@aws-sdk/client-appconfigdata": "3.1032.0", "@aws-sdk/client-athena": "3.1032.0", "@aws-sdk/client-bedrock-runtime": "3.1032.0", @@ -133,6 +139,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", + "@daytonaio/sdk": "0.197.0", "@e2b/code-interpreter": "^2.0.0", "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", @@ -588,9 +595,8 @@ }, }, "trustedDependencies": [ - "ffmpeg-static", - "isolated-vm", "sharp", + "isolated-vm", ], "overrides": { "@next/env": "16.2.11", @@ -601,6 +607,7 @@ "postgres": "^3.4.5", "react": "19.2.4", "react-dom": "19.2.4", + "zod": "4.3.6", }, "packages": { "@1password/sdk": ["@1password/sdk@0.3.1", "", { "dependencies": { "@1password/sdk-core": "0.3.1" } }, "sha512-20zbQfqsjcECT0gvnAw4zONJDt3XQgNH946pZR0NV1Qxukyaz/DKB0cBnBNCCEWZg93Bah8poaR6gJCyuNX14w=="], @@ -651,7 +658,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.114.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-zRFGTVMFEm77gt70Q0B+CDRKa9AcguydCcp6bD/dXWv8UkfsVFqCbaqU8+4B/pob3+Vy434LgtrBmwSvxfKr3g=="], "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], @@ -745,6 +752,8 @@ "@aws-sdk/lib-dynamodb": ["@aws-sdk/lib-dynamodb@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/util-dynamodb": "^3.996.2", "@smithy/core": "^3.23.15", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1032.0" } }, "sha512-rYGhqP1H0Fy4r1yvWTmEAx0qqy1Zd9OzI8pPkXo6KSEDjZ4EwU+6QN1V+KLX3XTU6FQouF5LTvqLtl/CW4gxyQ=="], + "@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.1088.0", "", { "dependencies": { "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.1088.0" } }, "sha512-OElyotfOuTLAkWeYfWvgFQ/ABVs5xi9+UzlnKveFnbyfzV9um0guDWGanG/xGlw9ofAiplscHlNauChtJUBNWA=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.25", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.52", "tslib": "^2.6.2" } }, "sha512-zcRjdhS46gQ+omEKod2Q83A+42dQlFgQP9GfsK2XcDCli8kzA3q1QH+hDpIZUDbKaXmkTSn0JG3WP5yds5j38g=="], "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.19", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.8", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FMgyzUq3Jh+ONRYxryBRNdBd+FUX8PwRl07ccQknNdoms6KCeAEusCkl6whqpDrPQ6OH0ddeSifKyqYSs2DLIw=="], @@ -947,6 +956,14 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@daytona/analytics-api-client": ["@daytona/analytics-api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-8S8JBVwIhErhDv22kCtifonfMnpQXtoAqz3migT53u7LCnjnVkqOeUFB/xN4SD7LN+Bhbh6AMVkvNwZA2BkIfw=="], + + "@daytona/api-client": ["@daytona/api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-O7BF07FOmmbNrp/An3EIx/Vq99wSHvxxWl4nUttw0eHpe7oKdYaUVlM2MmdJ/AbrFnYDLHfrQZGZfeUOEeRjpw=="], + + "@daytona/toolbox-api-client": ["@daytona/toolbox-api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-uBcbIAPcqeJUOpessqf2Za6Jid/Negn0x3wJlTcby391gsPUYDgsGzOmDZMs/rFKQ+/xtz/DAd7VThJZC5fnIQ=="], + + "@daytonaio/sdk": ["@daytonaio/sdk@0.197.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytona/analytics-api-client": "0.197.0", "@daytona/api-client": "0.197.0", "@daytona/toolbox-api-client": "0.197.0", "@iarna/toml": "^2.2.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation-http": "^0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/sdk-trace-base": "^2.8.0", "@opentelemetry/semantic-conventions": "^1.40.0", "axios": "^1.15.2", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "tar": "^7.5.11" } }, "sha512-RFrK/TLZy8S0VyQcXOzOv70VKNUoUyJ45u/HlCJjmXu5vyK3TH0pQJv9yJn8uT49W+4ypMSODcz0YJIRjH16VA=="], + "@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="], "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], @@ -1063,6 +1080,8 @@ "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], @@ -1217,6 +1236,14 @@ "@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w=="], + "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], @@ -1289,9 +1316,11 @@ "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.217.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.217.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w=="], - "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + "@opentelemetry/instrumentation-http": ["@opentelemetry/instrumentation-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/semantic-conventions": "^1.29.0", "forwarded-parse": "2.1.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-nNt1fqpyah/OKjNHdEOu8xLwISppRU2qJuF8aR+fCcftVwdFkPgtworBLA+TI1HU2iF508jcQBF2gerWczJAXg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-transformer": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA=="], - "@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.217.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.217.0", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7RTAdZuOsCDnsyqTCG4+bDzrfnsWdzkRs7z0AVi/V3tEQx0oKeyc+OuRWYxnRsmaJXgxcmB8vb/lfxn58Dj6Ag=="], + "@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-iIk/s8QQu39zpTrRRmsW/Eg3SE2+Hg8tLWepr2FLRgmwUpNd0IpCTLJEHJ77hpt4hgIS8MAh44UYI4xQPZwWlw=="], "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.217.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.217.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.217.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "protobufjs": "8.0.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA=="], @@ -2197,7 +2226,7 @@ "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -2619,6 +2648,8 @@ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expand-tilde": ["expand-tilde@2.0.2", "", { "dependencies": { "homedir-polyfill": "^1.0.1" } }, "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -2689,6 +2720,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], @@ -2805,6 +2838,8 @@ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "homedir-polyfill": ["homedir-polyfill@1.0.3", "", { "dependencies": { "parse-passwd": "^1.0.0" } }, "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], @@ -2929,6 +2964,8 @@ "isomorphic-unfetch": ["isomorphic-unfetch@3.1.0", "", { "dependencies": { "node-fetch": "^2.6.1", "unfetch": "^4.2.0" } }, "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], @@ -3391,6 +3428,8 @@ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -3747,6 +3786,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "shiki": ["shiki@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/engine-oniguruma": "4.3.1", "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw=="], "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], @@ -3823,6 +3864,8 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], + "stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="], "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], @@ -4163,6 +4206,10 @@ "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1069.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/nested-clients": "^3.997.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ks4X+kngC3PA5howV7Qu1TgG4bfC4jPykKdvw3nmBSXR9yZxRJouBholFSNQ5kY3L+Fgwyw+LCjzQmNi+KR91g=="], + "@aws-sdk/lib-storage/@smithy/core": ["@smithy/core@3.29.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg=="], + + "@aws-sdk/lib-storage/@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "@azure/communication-email/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -4179,16 +4226,10 @@ "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@better-auth/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], - "@better-auth/sso/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "@better-auth/stripe/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@browserbasehq/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@browserbasehq/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4199,6 +4240,12 @@ "@cerebras/cerebras_cloud_sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/configuration": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-logs-otlp-proto": "0.219.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.219.0", "@opentelemetry/exporter-prometheus": "0.219.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-proto": "0.219.0", "@opentelemetry/exporter-zipkin": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/propagator-b3": "2.8.0", "@opentelemetry/propagator-jaeger": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g=="], + + "@daytonaio/sdk/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], @@ -4235,18 +4282,34 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + + "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.217.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.217.0", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7RTAdZuOsCDnsyqTCG4+bDzrfnsWdzkRs7z0AVi/V3tEQx0oKeyc+OuRWYxnRsmaJXgxcmB8vb/lfxn58Dj6Ag=="], + + "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + + "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + + "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.217.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.217.0", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7RTAdZuOsCDnsyqTCG4+bDzrfnsWdzkRs7z0AVi/V3tEQx0oKeyc+OuRWYxnRsmaJXgxcmB8vb/lfxn58Dj6Ag=="], + "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], @@ -4255,14 +4318,22 @@ "@opentelemetry/exporter-prometheus/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + + "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.217.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.217.0", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7RTAdZuOsCDnsyqTCG4+bDzrfnsWdzkRs7z0AVi/V3tEQx0oKeyc+OuRWYxnRsmaJXgxcmB8vb/lfxn58Dj6Ag=="], + "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], @@ -4271,6 +4342,18 @@ "@opentelemetry/exporter-zipkin/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/instrumentation-http/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], @@ -4285,6 +4368,8 @@ "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + "@opentelemetry/sdk-node/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.217.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.217.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ=="], + "@opentelemetry/sdk-node/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], @@ -4515,8 +4600,6 @@ "@trigger.dev/core/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "@trigger.dev/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@trigger.dev/sdk/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@trigger.dev/sdk/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], @@ -4555,8 +4638,6 @@ "better-auth/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "better-auth/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4605,8 +4686,6 @@ "docs/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "docs/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "docx/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], @@ -4655,8 +4734,6 @@ "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], - "fumadocs-mdx/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -4749,6 +4826,8 @@ "mysql2/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "neo4j-driver-bolt-connection/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "neo4j-driver-bolt-connection/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -4845,6 +4924,8 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "streamdown/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -4889,10 +4970,6 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "zod-error/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "zod-validation-error/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -4911,6 +4988,46 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/configuration": ["@opentelemetry/configuration@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "yaml": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg=="], @@ -4967,6 +5084,16 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + "@opentelemetry/instrumentation-http/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + + "@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + "@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], @@ -5265,6 +5392,8 @@ "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "readable-web-to-node-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -5283,6 +5412,8 @@ "simstudio/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "stream-browserify/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -5359,6 +5490,26 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], "@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], diff --git a/bunfig.toml b/bunfig.toml index dbb4f93a7fa..74e468cd7e8 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -7,10 +7,17 @@ minimumReleaseAge = 604800 # dev builds, so every version is structurally younger than any age gate. # The exactly pinned Pi 0.80.10 packages were vetted for the cloud-review SDK # migration; they age out of the gate on 2026-07-24 — drop these four entries then. -# next@16.2.11 and @next/env@16.2.11 are the official Vercel security patch for the -# July 2026 advisories (SSRF, cache confusion, DoS, middleware bypass — GHSA-89xv-2m56-2m9x -# et al.); published 2026-07-21, they age out of the gate on 2026-07-28 — drop these two -# entries then. +# next@16.2.11, @next/env@16.2.11 and the @next/swc-* binaries are the official Vercel +# security patch for the July 2026 advisories (SSRF, cache confusion, DoS, middleware +# bypass — GHSA-89xv-2m56-2m9x et al.); published 2026-07-21, they age out of the gate on +# 2026-07-28 — drop these entries then. The swc binaries ship in lockstep with next and +# MUST be excluded alongside it: they are next's platform-gated optionalDependencies, so +# gating them out leaves them absent from bun.lock entirely, and `bun install +# --frozen-lockfile` then installs no compiler at all — next falls back to downloading one +# at build time, which fails in CI. +# @anthropic-ai/sdk is exactly pinned to 0.114.0, vetted for the agent-events +# streaming work (adaptive thinking display types + transform-json-schema); +# published 2026-07-23, it ages out of the gate on 2026-07-30 — drop this entry then. minimumReleaseAgeExcludes = [ "@typescript/native-preview", "@earendil-works/pi-agent-core", @@ -19,6 +26,11 @@ minimumReleaseAgeExcludes = [ "@earendil-works/pi-tui", "next", "@next/env", + "@next/swc-darwin-arm64", + "@next/swc-darwin-x64", + "@next/swc-linux-arm64-gnu", + "@next/swc-linux-x64-gnu", + "@anthropic-ai/sdk", ] [run] diff --git a/helm/sim/.claude/skills/sim-helm/SKILL.md b/helm/sim/.claude/skills/sim-helm/SKILL.md index ff693024fcc..4aa3283ec60 100644 --- a/helm/sim/.claude/skills/sim-helm/SKILL.md +++ b/helm/sim/.claude/skills/sim-helm/SKILL.md @@ -121,7 +121,7 @@ These are non-negotiable. Violating any of these has burned users in the past. 2. **Never set `image.tag: latest`.** The chart defaults to `Chart.AppVersion` for a reason — reproducible rollouts. If the user pinned `latest`, push back. 3. **Never edit chart templates to work around a `fail` statement.** The validation exists because a misconfiguration would otherwise surface as a runtime CrashLoopBackOff with cryptic env errors. 4. **Never drop `automountServiceAccountToken: false`** unless the workload genuinely needs in-cluster API access (Sim's app/realtime/postgres pods do not). -5. **Never `kubectl delete sts` without `--cascade=orphan`** on a live Postgres. It deletes the pods and PVCs. +5. **Never `kubectl delete sts` without `--cascade=orphan`** on a live Postgres. It deletes the pods (PVCs survive, but the database goes down immediately). 6. **Never tell a user "the chart works on your cluster" without `helm lint` + `helm template` against their values.** Static reading is not validation. 7. **Always confirm before `helm uninstall` in a shared namespace.** PVCs survive but other namespace resources may not. @@ -136,7 +136,7 @@ kubectl --namespace get pods,events --sort-by='.lastTimestamp' kubectl --namespace logs deploy/sim-app --tail=200 kubectl --namespace logs deploy/sim-realtime --tail=200 kubectl --namespace logs sts/sim-postgresql --tail=200 -kubectl --namespace logs job/sim-migrations --tail=200 2>/dev/null +kubectl --namespace logs deploy/sim-app -c migrations --tail=200 2>/dev/null kubectl --namespace describe pod -l app.kubernetes.io/name=sim ``` diff --git a/helm/sim/.claude/skills/sim-helm/references/secrets.md b/helm/sim/.claude/skills/sim-helm/references/secrets.md index de6e241b70e..e408b1cddd4 100644 --- a/helm/sim/.claude/skills/sim-helm/references/secrets.md +++ b/helm/sim/.claude/skills/sim-helm/references/secrets.md @@ -24,7 +24,7 @@ export POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=') # if using ch | `INTERNAL_API_SECRET` | Shared auth between `sim-app` ↔ `sim-realtime` pods | 32 bytes = 64 hex chars | Both deployments must roll together — temporary realtime errors during the rollout | | `CRON_SECRET` | Authenticates scheduled CronJob pods to the app | 32 bytes = 64 hex chars | Rotating just needs `helm upgrade`; next cron run uses the new value | | `API_ENCRYPTION_KEY` (optional) | Encrypts user-stored API keys (OpenAI tokens, etc.) at rest in Postgres | **Exactly 64 hex chars** (the app rejects other lengths) | Without it, keys are stored plain. Once set, never rotate without a migration | -| `POSTGRES_PASSWORD` (chart-bundled Postgres only) | Postgres superuser password | Any length ≥ 12 chars matching `^[a-zA-Z0-9._-]+$` | Requires Postgres pod restart + app rollout | +| `POSTGRES_PASSWORD` (chart-bundled Postgres only) | Postgres superuser password | ≥ 8 chars (schema-enforced) matching `^[a-zA-Z0-9._-]+$`; 32-byte hex recommended | Requires Postgres pod restart + app rollout | The `^[a-zA-Z0-9._-]+$` constraint on the Postgres password exists because the chart embeds the password into `DATABASE_URL` without URL-encoding. The `tr -d '/+='` strips the three problematic characters from `openssl rand -base64` output. The chart enforces this regex at template time. diff --git a/helm/sim/.claude/skills/sim-helm/references/troubleshooting.md b/helm/sim/.claude/skills/sim-helm/references/troubleshooting.md index 1bc1c2df47e..a4ce19fbe53 100644 --- a/helm/sim/.claude/skills/sim-helm/references/troubleshooting.md +++ b/helm/sim/.claude/skills/sim-helm/references/troubleshooting.md @@ -11,7 +11,7 @@ kubectl --namespace $NS describe pod -l app.kubernetes.io/instance=sim kubectl --namespace $NS logs deploy/sim-app --tail=200 kubectl --namespace $NS logs deploy/sim-realtime --tail=200 kubectl --namespace $NS logs sts/sim-postgresql --tail=200 -kubectl --namespace $NS logs job/sim-migrations --tail=200 2>/dev/null || true +kubectl --namespace $NS logs deploy/sim-app -c migrations --tail=200 2>/dev/null || true ``` --- @@ -63,9 +63,9 @@ Match the error: |---|---|---| | `Invalid env: ... NEXT_PUBLIC_APP_URL: Invalid url` | URL field set to empty string or invalid format | Set `app.env.NEXT_PUBLIC_APP_URL` to a valid URL — `https://sim.example.com` in prod, `http://localhost:3000` in dev | | `getaddrinfo ENOTFOUND ... -postgresql` / `connect ECONNREFUSED` | App can't reach Postgres | Check `kubectl get pod -l app.kubernetes.io/name=postgresql` is `Running`; check `postgresql.auth.password` matches the password in the Secret | -| `password authentication failed for user "sim"` | Postgres password rotated but app pod wasn't restarted, OR password contains URL-unsafe chars | `kubectl rollout restart deploy/sim-app -n sim`; regenerate password with `openssl rand -base64 24 \| tr -d '/+='` | +| `password authentication failed for user "postgres"` | Postgres password rotated but app pod wasn't restarted, OR password contains URL-unsafe chars | `kubectl rollout restart deploy/sim-app -n sim`; regenerate password with `openssl rand -base64 24 \| tr -d '/+='` | | `BETTER_AUTH_SECRET is missing` / `INTERNAL_API_SECRET is required` | Required env var not present in the Secret | Verify with `kubectl get secret sim-app-secrets -o jsonpath='{.data}' \| jq 'keys'`; if missing, fix your secret strategy | -| `Migration failed` or app starts before migration | Migration Job hasn't completed | `kubectl logs job/sim-migrations -n sim`; rerun with `kubectl delete job/sim-migrations && helm upgrade ...` | +| `Migration failed` | Migrations init container on the app pod failed | `kubectl logs deploy/sim-app -c migrations -n sim`; rerun with `kubectl rollout restart deploy/sim-app -n sim` (migrations run before each app pod starts, so the app can never start ahead of them) | --- @@ -112,7 +112,7 @@ kubectl describe ingress -n sim | No `ADDRESS` in `kubectl get ingress` | Ingress controller not installed — install `ingress-nginx`, AWS LBC, GCP LB controller, etc. | | `ingressClassName` doesn't match installed controller | `kubectl get ingressclass` to list installed classes, set `ingress.className` to match | | Address is set but DNS resolves to wrong IP | `dig ` — point DNS at the ingress controller's external IP / LoadBalancer / CNAME | -| TLS cert errors | If using cert-manager, check `kubectl describe certificate -n sim`; verify `ingress.tls.issuerRef` | +| TLS cert errors | If using cert-manager, check `kubectl describe certificate -n sim`; verify the `cert-manager.io/cluster-issuer` annotation on the ingress | | `503 Service Unavailable` | Ingress routing is fine but app pod isn't `Ready` — go back to the diagnostic block | --- @@ -153,7 +153,7 @@ Bump the relevant resource limit. Defaults: |---|---|---| | `app` | `1000m` CPU / `4Gi` memory | `2000m` CPU / `8Gi` memory | | `realtime` | `250m` CPU / `512Mi` memory | `500m` CPU / `1Gi` memory | -| `postgresql` | `250m` CPU / `512Mi` memory | `1000m` CPU / `2Gi` memory | +| `postgresql` | `500m` CPU / `1Gi` memory | `2Gi` memory (no CPU limit) | Override in values: @@ -172,7 +172,7 @@ app: ```bash helm version -kubectl version --short +kubectl version helm get values sim -n sim --revision $(helm history sim -n sim | tail -1 | awk '{print $1}') kubectl get all,pvc,ingress,externalsecret -n sim -o wide kubectl describe pods -n sim -l app.kubernetes.io/instance=sim | head -200 diff --git a/helm/sim/.claude/skills/sim-helm/references/values-model.md b/helm/sim/.claude/skills/sim-helm/references/values-model.md index 801dfd77b7f..58cb68c96f4 100644 --- a/helm/sim/.claude/skills/sim-helm/references/values-model.md +++ b/helm/sim/.claude/skills/sim-helm/references/values-model.md @@ -27,7 +27,7 @@ The Sim chart splits configuration across **four** layers. Understanding which l ▼ ┌───────────────────────────────────────────────────────────────────────────┐ │ Layer 3: chart-computed (inline env: on the Deployment) │ -│ → DATABASE_URL, SOCKET_SERVER_URL, OLLAMA_URL │ +│ → DATABASE_URL, SOCKET_SERVER_URL, OLLAMA_URL, PII_URL │ │ → Derived from postgresql.* / externalDatabase.* / service.* values │ │ → CANNOT be overridden via app.env — chart filters them out │ └───────────────────────────────────────────────────────────────────────────┘ @@ -63,7 +63,7 @@ The exhaustive list of keys per layer lives in `helm/sim/values.yaml`. Read the | Rate limits | 2 (envDefaults) | `RATE_LIMIT_WINDOW_MS`, `RATE_LIMIT_FREE_SYNC`, etc. | | Execution timeouts | 2 (envDefaults) | `EXECUTION_TIMEOUT_FREE`, `EXECUTION_TIMEOUT_PRO`, etc. | | IVM pool / quotas | 2 (envDefaults) | `IVM_POOL_SIZE`, `IVM_MAX_CONCURRENT`, `IVM_MAX_PER_WORKER`, etc. | -| Connection strings | 3 (chart-computed) | `DATABASE_URL`, `SOCKET_SERVER_URL`, `OLLAMA_URL` | +| Connection strings | 4 (chart-computed) | `DATABASE_URL`, `SOCKET_SERVER_URL`, `OLLAMA_URL`, `PII_URL` | | Custom downward API / configMapKeyRef | 4 (extraEnvVars) | anything that needs `valueFrom:` | ## Common authoring patterns diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index 1618461b105..9550ef6d9c1 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.0.0 -appVersion: "0.6.73" +version: 1.2.0 +appVersion: "v0.7.44" kubeVersion: ">=1.25.0-0" home: https://sim.ai icon: https://raw.githubusercontent.com/simstudioai/sim/main/apps/sim/public/logo/primary/primary.svg diff --git a/helm/sim/README.md b/helm/sim/README.md index 908aa970eac..e48eaeb4220 100644 --- a/helm/sim/README.md +++ b/helm/sim/README.md @@ -40,8 +40,8 @@ This chart deploys the Sim platform on a Kubernetes cluster using the Helm packa * **`app`** — the Sim Next.js web application (Deployment). * **`realtime`** — the WebSocket service for live workflow updates (Deployment). * **`postgresql`** — an in-cluster `pgvector/pgvector` Postgres (StatefulSet, with a headless Service for stable per-pod DNS). -* **`migrations`** — a Job that applies database migrations on install/upgrade. -* **`cronjobs`** — scheduled jobs for workflow schedule execution, inbox/calendar/drive polling (Gmail, Outlook, Calendar, Drive, Sheets, IMAP, RSS), workspace event polling, subscription renewal, data drains, and connector syncs. +* **`migrations`** — an init container on the app Deployment that applies database migrations before each app pod starts. +* **`cronjobs`** — scheduled jobs for workflow schedule execution, inbox/calendar/drive polling (Gmail, Outlook, Calendar, Drive, Sheets, IMAP, RSS), workspace event and HubSpot webhook polling, outbox processing, subscription renewal, billing-seat and inbox-entitlement reconciliation, time-pause/resume polling, data drains, and connector syncs. * **`serviceaccount`** — a dedicated ServiceAccount with `automountServiceAccountToken: false`. Optional components (off by default): @@ -214,7 +214,7 @@ cat ./helm/sim/values.schema.json Before installing in production, confirm each of the following: -* **High availability** — scale `app.replicaCount > 1`. The chart auto-creates a `PodDisruptionBudget` with `minAvailable: 1`. Set `podDisruptionBudget.maxUnavailable: "25%"` for a more permissive policy or `minAvailable: "50%"` for a stricter one. +* **High availability** — scale `app.replicaCount > 1`. The chart auto-creates a `PodDisruptionBudget` with `maxUnavailable: "25%"`. Set `podDisruptionBudget.minAvailable` instead for a stricter policy. * **Pinned images** — override `image.tag` (or `image.digest`) with an explicit version. Do not rely on the chart's default tag in production. * **Secrets management** — provide secrets via External Secrets Operator (ESO) or pre-created Kubernetes Secrets. Never commit secrets to `values.yaml`. * **TLS / Ingress** — set the `cert-manager.io/cluster-issuer` annotation on the ingress and tune `proxy-body-size` / `proxy-read-timeout` for your workload. See commented examples in `values.yaml`. @@ -271,8 +271,7 @@ postgresql: auth: existingSecret: enabled: true - name: sim-postgres-secret - passwordKey: POSTGRES_PASSWORD + name: sim-postgres-secret # must contain the password under the key POSTGRES_PASSWORD ``` See `examples/values-existing-secret.yaml`. @@ -357,7 +356,7 @@ autoscaling: targetMemoryUtilizationPercentage: 80 ``` -When `autoscaling.enabled=true`, the chart omits `spec.replicas` from the Deployment so the HPA owns replica count. Requires `metrics-server` in the cluster. +When `autoscaling.enabled=true`, the chart omits `spec.replicas` from the Deployment so the HPA owns replica count. Requires `metrics-server` in the cluster. The realtime Deployment gets the same HPA unless `autoscaling.realtime.enabled=false` — scale realtime past one replica only with `REDIS_URL` set (Socket.IO Redis adapter), or cross-pod collaboration events are dropped. --- @@ -370,7 +369,7 @@ monitoring: interval: 30s ``` -Requires the Prometheus Operator CRDs. Scrapes `/metrics` on the app and realtime services. +Requires the Prometheus Operator CRDs. Scrapes `/metrics` on the app and realtime services — note the default images do not currently expose a `/metrics` endpoint, so enable this only with a build that does. --- @@ -427,7 +426,7 @@ Common causes: * `NEXT_PUBLIC_APP_URL` still set to `http://localhost:3000` in a clustered deploy → set it to your public origin. * `DATABASE_URL` not reachable → check the Postgres pod is running and `postgresql.auth.password` matches. -* Missing migration → check `kubectl logs job/sim-migrations`. +* Missing migration → check `kubectl logs deploy/sim-app -c migrations` (migrations run as an init container on the app pod). ### Image pull errors (`ErrImagePull` / `ImagePullBackOff`) @@ -463,11 +462,26 @@ kubectl describe ingress --namespace sim kubectl --namespace sim logs -f deployment/sim-app kubectl --namespace sim logs -f deployment/sim-realtime kubectl --namespace sim logs -f statefulset/sim-postgresql -kubectl --namespace sim logs job/sim-migrations +kubectl --namespace sim logs deploy/sim-app -c migrations ``` --- +## Upgrading to 1.2.0 + +* `appVersion` (the default image tag when `image.tag` is unset) is now `v0.7.44` — the previous `0.6.73` referenced a tag that does not exist on GHCR, so an unpinned default install could not pull images. Production installs should still pin `image.tag` explicitly. +* `externalSecrets.apiVersion` now defaults to `"v1"` — current External Secrets Operator releases no longer serve `v1beta1` (removed upstream in 2026). Set `externalSecrets.apiVersion: "v1beta1"` only if you still run ESO < 0.17. +* `values.schema.json` now declares every top-level key and rejects unknown top-level keys, so a typo like `networkPolciy:` fails fast at install time instead of being silently ignored. If an upgrade suddenly fails schema validation, check your values file for stray top-level keys. +* The opt-in telemetry collector no longer ships a Prometheus scrape config for the app/realtime services (they expose no `/metrics` endpoint); OTLP ingestion is unchanged. + +## Upgrading to 1.1.0 + +No action is required for working configurations. Notes: + +* Pods for `app` and `realtime` roll once on upgrade (their rollout checksum now also covers the ExternalSecret manifest, fixing missed rollouts in ESO mode). +* Two values keys that were never consumed by any template were removed: `app.secrets.existingSecret.keys` and `*.existingSecret.passwordKey`. Existing secrets must use the standard key names (`BETTER_AUTH_SECRET`, ..., `POSTGRES_PASSWORD`, `EXTERNAL_DB_PASSWORD`); leftover keys in your values file are ignored, not rejected. +* `telemetry.jaeger` now exports over OTLP (`otlp/jaeger`) — point `telemetry.jaeger.endpoint` at Jaeger's OTLP gRPC port (4317). The previous `jaeger` exporter did not exist in the pinned collector image, so any prior jaeger-enabled config was already failing at collector startup. + ## Support * **Docs:** https://docs.sim.ai diff --git a/helm/sim/ci/default-values.yaml b/helm/sim/ci/default-values.yaml new file mode 100644 index 00000000000..f27cd58a49b --- /dev/null +++ b/helm/sim/ci/default-values.yaml @@ -0,0 +1,12 @@ +# CI-only values: the minimum required secrets so `helm lint`/`helm template` +# render the DEFAULT configuration. Dummy values — never use in a deployment. +app: + env: + BETTER_AUTH_SECRET: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + ENCRYPTION_KEY: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + INTERNAL_API_SECRET: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + CRON_SECRET: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + +postgresql: + auth: + password: "ci-dummy-password" diff --git a/helm/sim/ci/full-values.yaml b/helm/sim/ci/full-values.yaml new file mode 100644 index 00000000000..ca980273414 --- /dev/null +++ b/helm/sim/ci/full-values.yaml @@ -0,0 +1,72 @@ +# CI-only values: every optional component enabled so the full template surface +# renders and validates. Dummy values — never use in a deployment. +app: + env: + BETTER_AUTH_SECRET: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + ENCRYPTION_KEY: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + INTERNAL_API_SECRET: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + CRON_SECRET: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + API_ENCRYPTION_KEY: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + +postgresql: + auth: + password: "ci-dummy-password" + tls: + enabled: true + +certManager: + enabled: true + +ingress: + enabled: true + app: + host: ci.example.com + paths: [{ path: /, pathType: Prefix }] + realtime: + host: ci-ws.example.com + paths: [{ path: /, pathType: Prefix }] + tls: + enabled: true + +networkPolicy: + enabled: true + +autoscaling: + enabled: true + +monitoring: + serviceMonitor: + enabled: true + +telemetry: + enabled: true + jaeger: + enabled: true + prometheus: + enabled: true + otlp: + enabled: true + +sharedStorage: + enabled: true + +ollama: + enabled: true + +pii: + enabled: true + +copilot: + enabled: true + postgresql: + auth: + password: "ci-dummy-password" + server: + env: + AGENT_API_DB_ENCRYPTION_KEY: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + INTERNAL_API_SECRET: "cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici" + LICENSE_KEY: "ci-dummy-license" + SIM_BASE_URL: "https://ci.example.com" + SIM_AGENT_API_KEY: "ci-dummy-agent-key" + REDIS_URL: "redis://ci-redis:6379" + OPENAI_API_KEY_1: "ci-dummy-openai-key" diff --git a/helm/sim/ci/kind-values.yaml b/helm/sim/ci/kind-values.yaml new file mode 100644 index 00000000000..cf3185d63d6 --- /dev/null +++ b/helm/sim/ci/kind-values.yaml @@ -0,0 +1,39 @@ +# CI-only overlay for the kind install test: shrink resource requests so the +# default configuration schedules on a small CI runner. Layered on top of +# ci/default-values.yaml. Dummy sizing — never use in a deployment. +app: + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + +realtime: + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +migrations: + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + +postgresql: + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + persistence: + size: 2Gi diff --git a/helm/sim/examples/values-aws.yaml b/helm/sim/examples/values-aws.yaml index a0837b2672b..3be69b72be1 100644 --- a/helm/sim/examples/values-aws.yaml +++ b/helm/sim/examples/values-aws.yaml @@ -8,7 +8,9 @@ # - EKS cluster (Kubernetes 1.25+) # - EBS CSI driver add-on (aws eks create-addon --addon-name aws-ebs-csi-driver) # - AWS Load Balancer Controller (for ALB ingress) -# - (recommended) cert-manager OR an ACM certificate ARN for the ingress +# - An ACM certificate for your domains (ALB auto-discovers it from the ingress +# hosts, or pin it with alb.ingress.kubernetes.io/certificate-arn). cert-manager +# does not work with ALB — ALB cannot serve Kubernetes TLS Secrets # # Install: # helm install sim ./helm/sim \ @@ -110,7 +112,11 @@ app: # Realtime service realtime: enabled: true - replicaCount: 2 + # Keep realtime at 1 replica unless REDIS_URL is set (in app.env — realtime + # shares the app secret): Socket.IO only bridges events across pods through + # its Redis adapter, so multi-replica realtime without Redis silently drops + # cross-pod collaboration updates. + replicaCount: 1 # Node selector for realtime pods # Uncomment and customize based on your EKS node labels: @@ -171,7 +177,7 @@ postgresql: # Persistent storage using AWS EBS volumes persistence: enabled: true - storageClass: "gp2" # Use gp2 (default) or create gp3 StorageClass + storageClass: "gp3" # Matches the global gp3 StorageClass above size: 50Gi accessModes: - ReadWriteOnce @@ -222,7 +228,7 @@ ollama: # High-performance storage for AI models persistence: enabled: true - storageClass: "gp2" # Use gp2 (default) or create gp3 StorageClass + storageClass: "gp3" # Matches the global gp3 StorageClass above size: 100Gi accessModes: - ReadWriteOnce @@ -239,7 +245,6 @@ ingress: className: alb annotations: - kubernetes.io/ingress.class: alb alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/ssl-redirect: "443" @@ -284,7 +289,7 @@ affinity: matchExpressions: - key: app.kubernetes.io/name operator: In - values: ["simstudio"] + values: ["sim"] topologyKey: kubernetes.io/hostname - weight: 50 podAffinityTerm: @@ -292,7 +297,7 @@ affinity: matchExpressions: - key: app.kubernetes.io/name operator: In - values: ["simstudio"] + values: ["sim"] topologyKey: topology.kubernetes.io/zone # Service Account with IAM roles for service account (IRSA) integration diff --git a/helm/sim/examples/values-azure.yaml b/helm/sim/examples/values-azure.yaml index 2404088b221..5333570a427 100644 --- a/helm/sim/examples/values-azure.yaml +++ b/helm/sim/examples/values-azure.yaml @@ -7,8 +7,8 @@ # Prerequisites: # - AKS cluster (Kubernetes 1.25+) # - NGINX ingress controller installed -# - (optional) cert-manager for TLS -# - (optional) GPU node pool labeled with role: datalake / accelerator: nvidia +# - cert-manager installed with a ClusterIssuer (issues the ingress TLS secret) +# - (optional) GPU node pool tainted with sku=gpu:NoSchedule for Ollama # # Install: # helm install sim ./helm/sim \ @@ -127,7 +127,11 @@ app: # Realtime service realtime: enabled: true - replicaCount: 2 + # Keep realtime at 1 replica unless REDIS_URL is set (in app.env — realtime + # shares the app secret): Socket.IO only bridges events across pods through + # its Redis adapter, so multi-replica realtime without Redis silently drops + # cross-pod collaboration updates. + replicaCount: 1 # Node selector for realtime pods # Uncomment and customize based on your AKS node labels: @@ -242,6 +246,10 @@ ingress: annotations: nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + # cert-manager issues the certificate into ingress.tls.secretName below — + # without this annotation (and cert-manager installed) nginx serves its + # self-signed fake certificate forever. + cert-manager.io/cluster-issuer: "letsencrypt-prod" # Main application app: diff --git a/helm/sim/examples/values-copilot.yaml b/helm/sim/examples/values-copilot.yaml index d95e1aa2e91..ff0a0b877ed 100644 --- a/helm/sim/examples/values-copilot.yaml +++ b/helm/sim/examples/values-copilot.yaml @@ -1,6 +1,6 @@ # values-copilot.yaml # -# When to use: enable the Sim Copilot service (the chat / Mothership backend) +# When to use: enable the Sim Copilot service (the backend for Chat — the Sim agent) # alongside the main app. Provisions a dedicated Copilot Deployment, its own # Postgres StatefulSet, and a migration Job for the Copilot schema. # diff --git a/helm/sim/examples/values-existing-secret.yaml b/helm/sim/examples/values-existing-secret.yaml index a875e99abae..0ddf0621ace 100644 --- a/helm/sim/examples/values-existing-secret.yaml +++ b/helm/sim/examples/values-existing-secret.yaml @@ -7,7 +7,7 @@ # # Prerequisites: # Create the Secret objects before `helm install`. Example: -# kubectl create secret generic my-app-secrets --namespace sim \ +# kubectl create secret generic sim-app-secrets --namespace sim \ # --from-literal=BETTER_AUTH_SECRET=$(openssl rand -hex 32) \ # --from-literal=ENCRYPTION_KEY=$(openssl rand -hex 32) \ # --from-literal=INTERNAL_API_SECRET=$(openssl rand -hex 32) \ @@ -34,7 +34,11 @@ app: realtime: enabled: true - replicaCount: 2 + # Keep realtime at 1 replica unless REDIS_URL is set (in app.env — realtime + # shares the app secret): Socket.IO only bridges events across pods through + # its Redis adapter, so multi-replica realtime without Redis silently drops + # cross-pod collaboration updates. + replicaCount: 1 env: NEXT_PUBLIC_APP_URL: "https://sim.example.com" BETTER_AUTH_URL: "https://sim.example.com" diff --git a/helm/sim/examples/values-external-db.yaml b/helm/sim/examples/values-external-db.yaml index 630b2f49bb9..9adfce02f66 100644 --- a/helm/sim/examples/values-external-db.yaml +++ b/helm/sim/examples/values-external-db.yaml @@ -11,7 +11,13 @@ # - Network reachability from the cluster (peering, private link, or public # endpoint with TLS) # -# Install: +# Install — generate each secret first in the same shell: +# export BETTER_AUTH_SECRET=$(openssl rand -hex 32) +# export ENCRYPTION_KEY=$(openssl rand -hex 32) +# export INTERNAL_API_SECRET=$(openssl rand -hex 32) +# export CRON_SECRET=$(openssl rand -hex 32) +# export DB_PASSWORD=$(openssl rand -hex 24) +# # helm install sim ./helm/sim \ # --namespace sim --create-namespace \ # --values ./helm/sim/examples/values-external-db.yaml \ @@ -19,9 +25,11 @@ # --set externalDatabase.username=simstudio_user \ # --set externalDatabase.password="$DB_PASSWORD" \ # --set externalDatabase.database=simstudio_prod \ +# --set "networkPolicy.egress[0].to[0].ipBlock.cidr=10.20.0.0/24" \ # --set app.env.BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ # --set app.env.ENCRYPTION_KEY="$ENCRYPTION_KEY" \ -# --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" +# --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" \ +# --set app.env.CRON_SECRET="$CRON_SECRET" # Global configuration global: @@ -63,7 +71,12 @@ app: # Realtime service realtime: enabled: true - replicaCount: 2 + # Keep realtime at 1 replica unless REDIS_URL is set (in app.env — realtime + # shares the app secret): Socket.IO only bridges events across pods through + # its Redis adapter, so multi-replica realtime without Redis silently drops + # cross-pod collaboration updates. autoscaling.realtime.enabled is false + # below for the same reason — flip both once REDIS_URL is set. + replicaCount: 1 resources: limits: @@ -101,9 +114,9 @@ externalDatabase: enabled: true # Database connection details (REQUIRED - configure for your external database) - host: "" # Database hostname (e.g., "postgres.acme.com" or RDS endpoint) + host: "postgres.acme.com" # REQUIRED - your database hostname (RDS/Cloud SQL/Azure endpoint or IP) port: 5432 - username: "" # Database username (e.g., "simstudio_user") + username: "simstudio_user" # REQUIRED - your database username password: "" # Database password - set via --set flag or external secret database: "" # Database name (e.g., "simstudio_production") @@ -142,6 +155,11 @@ ingress: # Production-ready features (autoscaling, monitoring, etc.) autoscaling: enabled: true + # Keep realtime out of the HPA until REDIS_URL is set (Socket.IO Redis + # adapter) — multi-replica realtime without Redis silently drops cross-pod + # collaboration events. + realtime: + enabled: false minReplicas: 2 maxReplicas: 20 targetCPUUtilizationPercentage: 70 @@ -161,14 +179,19 @@ monitoring: networkPolicy: enabled: true - # Custom egress rules to allow database connectivity to an external DB. - # The chart's app/realtime NetworkPolicies already permit egress to - # `postgresql.service.targetPort` when `postgresql.enabled=true`. For an - # external DB on a different port (or off-cluster), append to extraRules. + # `networkPolicy.egress` is a LIST of NetworkPolicy egress rules. With the + # policy on, the chart's egress allowlist only covers the in-cluster + # Postgres, so this rule is what lets the pods reach your external database. + # The CIDR below is an example, exactly like `your-db.example.com` in the + # install command: REQUIRED to change — set your database's subnet (or /32 + # host) via the --set line in the install commands below, or edit it here. + # A wrong CIDR blocks migrations and every database connection; an empty + # `to:` would allow ANY host on 5432 and defeat the isolation. egress: - extraRules: - - to: [] - ports: + - to: + - ipBlock: + cidr: 10.0.0.0/16 # REQUIRED - your database subnet or /32 host CIDR + ports: - protocol: TCP port: 5432 @@ -179,6 +202,7 @@ networkPolicy: # --set externalDatabase.username="your-db-user" \ # --set externalDatabase.password="your-db-password" \ # --set externalDatabase.database="your-db-name" \ +# --set "networkPolicy.egress[0].to[0].ipBlock.cidr=10.20.0.0/24" \ # --set app.env.BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \ # --set app.env.ENCRYPTION_KEY="$(openssl rand -hex 32)" \ # --set app.env.INTERNAL_API_SECRET="$(openssl rand -hex 32)" \ diff --git a/helm/sim/examples/values-external-secrets.yaml b/helm/sim/examples/values-external-secrets.yaml index 23aa6c5cfbc..e9533fc1686 100644 --- a/helm/sim/examples/values-external-secrets.yaml +++ b/helm/sim/examples/values-external-secrets.yaml @@ -63,7 +63,11 @@ app: realtime: enabled: true - replicaCount: 2 + # Keep realtime at 1 replica unless REDIS_URL is set (in app.env — realtime + # shares the app secret): Socket.IO only bridges events across pods through + # its Redis adapter, so multi-replica realtime without Redis silently drops + # cross-pod collaboration updates. + replicaCount: 1 envDefaults: NEXT_PUBLIC_APP_URL: "https://sim.example.com" BETTER_AUTH_URL: "https://sim.example.com" @@ -81,7 +85,7 @@ postgresql: # --- # Azure Key Vault (Workload Identity): -# apiVersion: external-secrets.io/v1beta1 +# apiVersion: external-secrets.io/v1 # kind: ClusterSecretStore # metadata: # name: sim-secret-store @@ -95,7 +99,7 @@ postgresql: # namespace: external-secrets # AWS Secrets Manager (IRSA): -# apiVersion: external-secrets.io/v1beta1 +# apiVersion: external-secrets.io/v1 # kind: ClusterSecretStore # metadata: # name: sim-secret-store @@ -107,7 +111,7 @@ postgresql: # role: arn:aws:iam::123456789012:role/external-secrets-role # HashiCorp Vault (Kubernetes Auth): -# apiVersion: external-secrets.io/v1beta1 +# apiVersion: external-secrets.io/v1 # kind: ClusterSecretStore # metadata: # name: sim-secret-store diff --git a/helm/sim/examples/values-gcp.yaml b/helm/sim/examples/values-gcp.yaml index 545d955ff97..fca78a7b01c 100644 --- a/helm/sim/examples/values-gcp.yaml +++ b/helm/sim/examples/values-gcp.yaml @@ -7,7 +7,7 @@ # Prerequisites: # - GKE cluster (Kubernetes 1.25+) # - Workload Identity enabled (for IAM-bound ServiceAccount) -# - (optional) Managed certificate or cert-manager for TLS +# - A GKE ManagedCertificate named simstudio-ssl-cert (creation manifest in the ingress section below) — or cert-manager if you prefer # - (optional) GPU node pool with accelerator labels for Ollama # # Install: @@ -86,10 +86,6 @@ app: NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" - # GCP-specific environment variables - GOOGLE_CLOUD_PROJECT: "your-project-id" - GOOGLE_CLOUD_REGION: "us-central1" - # Google Cloud Storage Configuration (RECOMMENDED for production) # Create GCS buckets in your project and grant the Workload Identity # service account roles/storage.objectAdmin on them. Signed-URL generation @@ -117,7 +113,11 @@ app: # Realtime service realtime: enabled: true - replicaCount: 2 + # Keep realtime at 1 replica unless REDIS_URL is set (in app.env — realtime + # shares the app secret): Socket.IO only bridges events across pods through + # its Redis adapter, so multi-replica realtime without Redis silently drops + # cross-pod collaboration updates. + replicaCount: 1 # Node selector for realtime pods # Uncomment and customize based on your GKE node labels: @@ -266,10 +266,29 @@ ingress: - path: / pathType: Prefix - # TLS configuration + # TLS comes from the GKE ManagedCertificate referenced by the + # networking.gke.io/managed-certificates annotation above. The chart does NOT + # create that resource — create it once before installing (the certificate + # provisions automatically after DNS resolves; allow ~15-30 minutes): + # + # kubectl create namespace sim + # cat <<'EOF' | kubectl apply -f - + # apiVersion: networking.gke.io/v1 + # kind: ManagedCertificate + # metadata: + # name: simstudio-ssl-cert + # namespace: sim + # spec: + # domains: + # - simstudio.acme.com + # - simstudio-ws.acme.com + # EOF + # + # Keep the chart's secret-based TLS off with GCE managed certificates — an + # enabled spec.tls references a Secret nothing creates, and the GKE ingress + # controller reports sync errors for it. tls: - enabled: true - secretName: simstudio-tls-secret + enabled: false # Pod disruption budget for high availability podDisruptionBudget: @@ -291,7 +310,7 @@ affinity: matchExpressions: - key: app.kubernetes.io/name operator: In - values: ["simstudio"] + values: ["sim"] topologyKey: kubernetes.io/hostname - weight: 50 podAffinityTerm: @@ -299,7 +318,7 @@ affinity: matchExpressions: - key: app.kubernetes.io/name operator: In - values: ["simstudio"] + values: ["sim"] topologyKey: topology.gke.io/zone # Service Account with Workload Identity integration @@ -308,21 +327,27 @@ serviceAccount: annotations: iam.gke.io/gcp-service-account: "simstudio@your-project-id.iam.gserviceaccount.com" -# Additional environment variables for GCP service integration -extraEnvVars: - - name: GOOGLE_APPLICATION_CREDENTIALS - value: "/var/secrets/google/key.json" - -# Additional volumes for service account credentials -extraVolumes: - - name: google-cloud-key - secret: - secretName: google-service-account-key - -extraVolumeMounts: - - name: google-cloud-key - mountPath: /var/secrets/google - readOnly: true +# Key-file credentials — ONLY if NOT using Workload Identity (the serviceAccount +# annotation above is the recommended path and needs no key file; a mounted +# GOOGLE_APPLICATION_CREDENTIALS overrides Workload Identity/ADC). +# If you do use a key file, create the Secret first or every pod is stuck in +# ContainerCreating on the missing volume: +# kubectl -n sim create secret generic google-service-account-key \ +# --from-file=key.json=/path/to/service-account-key.json +# +# extraEnvVars: +# - name: GOOGLE_APPLICATION_CREDENTIALS +# value: "/var/secrets/google/key.json" +# +# extraVolumes: +# - name: google-cloud-key +# secret: +# secretName: google-service-account-key +# +# extraVolumeMounts: +# - name: google-cloud-key +# mountPath: /var/secrets/google +# readOnly: true # ----------------------------------------------------------------------------- # External Secrets Operator (ESO) — recommended for production on GCP # ----------------------------------------------------------------------------- diff --git a/helm/sim/examples/values-production.yaml b/helm/sim/examples/values-production.yaml index 6f529e59d10..39afb262ba2 100644 --- a/helm/sim/examples/values-production.yaml +++ b/helm/sim/examples/values-production.yaml @@ -26,7 +26,9 @@ global: imageRegistry: "ghcr.io" # For production, use a StorageClass with reclaimPolicy: Retain - storageClass: "managed-csi-premium" + # Set to YOUR cluster's StorageClass: gp3 (EKS), standard-rwo (GKE), + # managed-csi-premium (AKS). Empty uses the cluster default. + storageClass: "" # Main application app: @@ -45,7 +47,6 @@ app: env: NEXT_PUBLIC_APP_URL: "https://sim.acme.ai" BETTER_AUTH_URL: "https://sim.acme.ai" - SOCKET_SERVER_URL: "https://sim-ws.acme.ai" NEXT_PUBLIC_SOCKET_URL: "https://sim-ws.acme.ai" # Security settings (REQUIRED - replace with your own secure secrets) @@ -72,7 +73,12 @@ app: # Realtime service realtime: enabled: true - replicaCount: 2 + # Keep realtime at 1 replica unless REDIS_URL is set (in app.env — realtime + # shares the app secret): Socket.IO only bridges events across pods through + # its Redis adapter, so multi-replica realtime without Redis silently drops + # cross-pod collaboration updates. autoscaling.realtime.enabled is false + # below for the same reason — flip both once REDIS_URL is set. + replicaCount: 1 resources: limits: @@ -121,10 +127,12 @@ postgresql: # Persistent storage configuration persistence: enabled: true - storageClass: "managed-csi-premium" + storageClass: "" # your cluster's StorageClass (empty = cluster default) size: 50Gi - # SSL/TLS configuration (recommended for production) + # SSL/TLS configuration (recommended for production). Requires + # certManager.enabled below — the Certificate's issuerRef (sim-ca-issuer) + # only exists when the chart creates its CA issuer. tls: enabled: true certificatesSecret: postgres-tls-secret @@ -168,9 +176,18 @@ ingress: enabled: true secretName: sim-tls-secret +# Chart-managed CA issuer — required for postgresql.tls above (issuerRef sim-ca-issuer) +certManager: + enabled: true + # Horizontal Pod Autoscaler (automatically scales pods based on CPU/memory usage) autoscaling: enabled: true + # Keep realtime out of the HPA until REDIS_URL is set (Socket.IO Redis + # adapter) — multi-replica realtime without Redis silently drops cross-pod + # collaboration events. + realtime: + enabled: false minReplicas: 2 maxReplicas: 20 targetCPUUtilizationPercentage: 70 @@ -242,4 +259,6 @@ telemetry: endpoint: "http://prometheus-server/api/v1/write" jaeger: enabled: true - endpoint: "http://jaeger-collector:14250" + # Jaeger's OTLP gRPC endpoint (the chart exports via otlp/jaeger; + # Jaeger >=1.35 ingests OTLP natively) + endpoint: "jaeger-collector:4317" diff --git a/helm/sim/examples/values-whitelabeled.yaml b/helm/sim/examples/values-whitelabeled.yaml index d7ec9c4a698..44d8b16c6ef 100644 --- a/helm/sim/examples/values-whitelabeled.yaml +++ b/helm/sim/examples/values-whitelabeled.yaml @@ -19,7 +19,7 @@ # Global configuration global: imageRegistry: "ghcr.io" - storageClass: "managed-csi-premium" + storageClass: "" # your cluster's StorageClass (empty = cluster default) # Main application with custom branding app: @@ -31,7 +31,6 @@ app: # Application URLs (update with your domain) NEXT_PUBLIC_APP_URL: "https://sim.acme.ai" BETTER_AUTH_URL: "https://sim.acme.ai" - SOCKET_SERVER_URL: "https://sim-ws.acme.ai" NEXT_PUBLIC_SOCKET_URL: "https://sim-ws.acme.ai" # Security settings (REQUIRED) @@ -101,6 +100,11 @@ ingress: # Auto-scaling autoscaling: enabled: true + # Keep realtime out of the HPA until REDIS_URL is set (Socket.IO Redis + # adapter) — multi-replica realtime without Redis silently drops cross-pod + # collaboration events. + realtime: + enabled: false minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70 diff --git a/helm/sim/templates/NOTES.txt b/helm/sim/templates/NOTES.txt index 250bdfd7685..c3f3e211f8a 100644 --- a/helm/sim/templates/NOTES.txt +++ b/helm/sim/templates/NOTES.txt @@ -46,7 +46,7 @@ Your release is named {{ .Release.Name }} in namespace {{ .Release.Namespace }}. 3. Required secrets: - Sim requires three application secrets and a Postgres password to start. + Sim requires four application secrets and a Postgres password to start. {{- if .Values.externalSecrets.enabled }} Using External Secrets Operator. Verify the ExternalSecret has synced: @@ -86,9 +86,9 @@ Your release is named {{ .Release.Name }} in namespace {{ .Release.Namespace }}. 5. Upgrade notes (read before upgrading from a chart version released before this one): - * externalSecrets.apiVersion default is "v1beta1" (was "v1"). v1beta1 is - supported by every ESO release from v0.7+ through current. If you're on - ESO v0.17+ and want the graduated v1 API, set externalSecrets.apiVersion: "v1". + * externalSecrets.apiVersion defaults to "v1". Current ESO releases no + longer serve v1beta1 (the compatibility path was removed in 2026) — set + externalSecrets.apiVersion: "v1beta1" only if you still run ESO < 0.17. * networkPolicy.egress remains a list of custom egress rules (unchanged). Cloud-metadata CIDR blocking is now configured via networkPolicy.egressExceptCidrs (defaults to AWS/GCP/Azure IMDS + ECS task metadata). diff --git a/helm/sim/templates/_helpers.tpl b/helm/sim/templates/_helpers.tpl index 32d917a33b6..4ecaf0d263b 100644 --- a/helm/sim/templates/_helpers.tpl +++ b/helm/sim/templates/_helpers.tpl @@ -133,14 +133,6 @@ PII (Presidio) selector labels app.kubernetes.io/component: pii {{- end }} -{{/* -Migrations specific labels -*/}} -{{- define "sim.migrations.labels" -}} -{{ include "sim.labels" . }} -app.kubernetes.io/component: migrations -{{- end }} - {{/* Copilot specific labels */}} @@ -393,18 +385,6 @@ Returns the name of the secret containing PostgreSQL password {{- end -}} {{- end }} -{{/* -Get the PostgreSQL password key name -Returns the key name in the secret that contains the password -*/}} -{{- define "sim.postgresqlPasswordKey" -}} -{{- if and .Values.postgresql.auth.existingSecret .Values.postgresql.auth.existingSecret.enabled -}} -{{- .Values.postgresql.auth.existingSecret.passwordKey | default "POSTGRES_PASSWORD" -}} -{{- else -}} -{{- print "POSTGRES_PASSWORD" -}} -{{- end -}} -{{- end }} - {{/* Get the external database secret name Returns the name of the secret containing external database password @@ -417,18 +397,6 @@ Returns the name of the secret containing external database password {{- end -}} {{- end }} -{{/* -Get the external database password key name -Returns the key name in the secret that contains the password -*/}} -{{- define "sim.externalDbPasswordKey" -}} -{{- if and .Values.externalDatabase.existingSecret .Values.externalDatabase.existingSecret.enabled -}} -{{- .Values.externalDatabase.existingSecret.passwordKey | default "EXTERNAL_DB_PASSWORD" -}} -{{- else -}} -{{- print "EXTERNAL_DB_PASSWORD" -}} -{{- end -}} -{{- end }} - {{/* Check if app secrets should be created by the chart Returns true if we should create the app secrets (not using existing or ESO) diff --git a/helm/sim/templates/deployment-app.yaml b/helm/sim/templates/deployment-app.yaml index 77a3d792e7f..f3506edf53c 100644 --- a/helm/sim/templates/deployment-app.yaml +++ b/helm/sim/templates/deployment-app.yaml @@ -17,7 +17,12 @@ spec: template: metadata: annotations: - checksum/secret: {{ include (print $.Template.BasePath "/secrets-app.yaml") . | sha256sum }} + {{- /* Hash the inline Secret AND the ExternalSecret manifest so spec + changes under externalSecrets.remoteRefs.app roll the pods (with ESO + enabled the inline Secret renders empty and would hash to a constant). + Rotated values inside the external store still can't be detected at + render time — ESO's own reconciliation handles those. */}} + checksum/secret: {{ printf "%s%s" (include (print $.Template.BasePath "/secrets-app.yaml") .) (include (print $.Template.BasePath "/external-secret-app.yaml") .) | sha256sum }} {{- if .Values.branding.enabled }} checksum/config: {{ include (print $.Template.BasePath "/configmap-branding.yaml") . | sha256sum }} {{- end }} diff --git a/helm/sim/templates/deployment-realtime.yaml b/helm/sim/templates/deployment-realtime.yaml index b46923d14c7..1e3487164bd 100644 --- a/helm/sim/templates/deployment-realtime.yaml +++ b/helm/sim/templates/deployment-realtime.yaml @@ -8,7 +8,9 @@ metadata: labels: {{- include "sim.realtime.labels" . | nindent 4 }} spec: - {{- if not .Values.autoscaling.enabled }} + {{- /* The HPA owns replicas only when the realtime HPA actually renders — + with autoscaling.realtime.enabled=false, replicaCount stays in charge. */}} + {{- if not (and .Values.autoscaling.enabled .Values.autoscaling.realtime.enabled) }} replicas: {{ .Values.realtime.replicaCount }} {{- end }} selector: @@ -17,7 +19,12 @@ spec: template: metadata: annotations: - checksum/secret: {{ include (print $.Template.BasePath "/secrets-app.yaml") . | sha256sum }} + {{- /* Hash the inline Secret AND the ExternalSecret manifest so spec + changes under externalSecrets.remoteRefs.app roll the pods (with ESO + enabled the inline Secret renders empty and would hash to a constant). + Rotated values inside the external store still can't be detected at + render time — ESO's own reconciliation handles those. */}} + checksum/secret: {{ printf "%s%s" (include (print $.Template.BasePath "/secrets-app.yaml") .) (include (print $.Template.BasePath "/external-secret-app.yaml") .) | sha256sum }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/helm/sim/templates/external-secret-app.yaml b/helm/sim/templates/external-secret-app.yaml index 826291e4eba..ea82d6a1f6e 100644 --- a/helm/sim/templates/external-secret-app.yaml +++ b/helm/sim/templates/external-secret-app.yaml @@ -9,7 +9,7 @@ # - a string: treated as the remoteRef.key (legacy form) # - a map: passed through as the remoteRef block (e.g. {key, property, # version, decodingStrategy, conversionStrategy, metadataPolicy}) -apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1beta1" }} +apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1" }} kind: ExternalSecret metadata: name: {{ include "sim.fullname" . }}-app-secrets diff --git a/helm/sim/templates/external-secret-copilot.yaml b/helm/sim/templates/external-secret-copilot.yaml index a7b78da0310..93cf7fb3bc8 100644 --- a/helm/sim/templates/external-secret-copilot.yaml +++ b/helm/sim/templates/external-secret-copilot.yaml @@ -6,7 +6,7 @@ # template rendering if a required key (or any non-empty copilot.server.env key) is # missing a matching remoteRefs.copilot entry, so a misconfiguration surfaces at # `helm template` time instead of a container starting with an empty value. -apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1beta1" }} +apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1" }} kind: ExternalSecret metadata: name: {{ include "sim.copilot.envSecretName" . }} diff --git a/helm/sim/templates/external-secret-external-db.yaml b/helm/sim/templates/external-secret-external-db.yaml index 1d2ac705e82..d1cde291aac 100644 --- a/helm/sim/templates/external-secret-external-db.yaml +++ b/helm/sim/templates/external-secret-external-db.yaml @@ -1,6 +1,6 @@ {{- if and .Values.externalSecrets.enabled .Values.externalDatabase.enabled .Values.externalSecrets.remoteRefs.externalDatabase.password }} # ExternalSecret for external database password (syncs from external secret managers) -apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1beta1" }} +apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1" }} kind: ExternalSecret metadata: name: {{ include "sim.fullname" . }}-external-db-secret diff --git a/helm/sim/templates/external-secret-postgresql.yaml b/helm/sim/templates/external-secret-postgresql.yaml index 7f55bff5424..d8796077d92 100644 --- a/helm/sim/templates/external-secret-postgresql.yaml +++ b/helm/sim/templates/external-secret-postgresql.yaml @@ -1,6 +1,6 @@ {{- if and .Values.externalSecrets.enabled .Values.postgresql.enabled .Values.externalSecrets.remoteRefs.postgresql.password }} # ExternalSecret for PostgreSQL password (syncs from external secret managers) -apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1beta1" }} +apiVersion: external-secrets.io/{{ .Values.externalSecrets.apiVersion | default "v1" }} kind: ExternalSecret metadata: name: {{ include "sim.fullname" . }}-postgresql-secret diff --git a/helm/sim/templates/hpa.yaml b/helm/sim/templates/hpa.yaml index 01c20058f80..4f1892d0b24 100644 --- a/helm/sim/templates/hpa.yaml +++ b/helm/sim/templates/hpa.yaml @@ -41,7 +41,7 @@ spec: {{- end }} {{- end }} -{{- if and .Values.autoscaling.enabled .Values.realtime.enabled }} +{{- if and .Values.autoscaling.enabled .Values.realtime.enabled .Values.autoscaling.realtime.enabled }} --- # HorizontalPodAutoscaler for realtime service apiVersion: autoscaling/v2 diff --git a/helm/sim/templates/job-copilot-migrations.yaml b/helm/sim/templates/job-copilot-migrations.yaml index 60a2bbe663f..862ef02e919 100644 --- a/helm/sim/templates/job-copilot-migrations.yaml +++ b/helm/sim/templates/job-copilot-migrations.yaml @@ -45,6 +45,13 @@ spec: sleep 2 done echo "Copilot PostgreSQL is ready!" + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi envFrom: - secretRef: name: {{ include "sim.fullname" . }}-copilot-postgresql-secret diff --git a/helm/sim/templates/networkpolicy.yaml b/helm/sim/templates/networkpolicy.yaml index 4488b0e22f5..69854cebbe4 100644 --- a/helm/sim/templates/networkpolicy.yaml +++ b/helm/sim/templates/networkpolicy.yaml @@ -16,7 +16,7 @@ spec: - Ingress - Egress ingress: - # Allow ingress from realtime service + # Allow ingress from realtime pods (defensive — current traffic flows app -> realtime only) {{- if .Values.realtime.enabled }} - from: - podSelector: @@ -237,7 +237,7 @@ spec: ports: - protocol: TCP port: {{ .Values.postgresql.service.targetPort }} - # Allow ingress from realtime service + # Allow ingress from realtime pods (defensive — current traffic flows app -> realtime only) {{- if .Values.realtime.enabled }} - from: - podSelector: @@ -247,16 +247,6 @@ spec: - protocol: TCP port: {{ .Values.postgresql.service.targetPort }} {{- end }} - # Allow ingress from migrations job - {{- if .Values.migrations.enabled }} - - from: - - podSelector: - matchLabels: - {{- include "sim.migrations.labels" . | nindent 10 }} - ports: - - protocol: TCP - port: {{ .Values.postgresql.service.targetPort }} - {{- end }} egress: # Allow minimal egress (for health checks, etc.) - to: [] diff --git a/helm/sim/templates/telemetry.yaml b/helm/sim/templates/telemetry.yaml index 71bfea320cf..bd0627d4284 100644 --- a/helm/sim/templates/telemetry.yaml +++ b/helm/sim/templates/telemetry.yaml @@ -18,26 +18,19 @@ data: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 - prometheus: - config: - scrape_configs: - - job_name: 'sim-app' - static_configs: - - targets: ['{{ include "sim.fullname" . }}-app:{{ .Values.app.service.port }}'] - - job_name: 'sim-realtime' - static_configs: - - targets: ['{{ include "sim.fullname" . }}-realtime:{{ .Values.realtime.service.port }}'] - processors: batch: timeout: 1s send_batch_size: 1024 memory_limiter: + check_interval: 1s limit_mib: 512 exporters: {{- if .Values.telemetry.jaeger.enabled }} - jaeger: + {{- /* The dedicated jaeger exporter was removed from collector-contrib in + v0.86; Jaeger >=1.35 ingests OTLP natively, so export via OTLP. */}} + otlp/jaeger: endpoint: {{ .Values.telemetry.jaeger.endpoint }} tls: insecure: {{ not .Values.telemetry.jaeger.tls.enabled }} @@ -74,13 +67,13 @@ data: exporters: - logging {{- if .Values.telemetry.jaeger.enabled }} - - jaeger + - otlp/jaeger {{- end }} {{- if .Values.telemetry.otlp.enabled }} - otlp {{- end }} metrics: - receivers: [otlp, prometheus] + receivers: [otlp] processors: [memory_limiter, batch] exporters: - logging diff --git a/helm/sim/templates/tests/test-connection.yaml b/helm/sim/templates/tests/test-connection.yaml index c6389ba4ede..f05c976ea4f 100644 --- a/helm/sim/templates/tests/test-connection.yaml +++ b/helm/sim/templates/tests/test-connection.yaml @@ -25,7 +25,7 @@ spec: type: RuntimeDefault containers: - name: probe - image: "{{ .Values.tests.image.repository }}:{{ .Values.tests.image.tag }}" + image: {{ include "sim.image" (dict "imageRoot" .Values.tests.image "global" .Values.global "chartAppVersion" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.tests.image.pullPolicy }} securityContext: allowPrivilegeEscalation: false diff --git a/helm/sim/tests/schema-secret-length_test.yaml b/helm/sim/tests/schema-secret-length_test.yaml index b2255d93853..f9b24c9b13c 100644 --- a/helm/sim/tests/schema-secret-length_test.yaml +++ b/helm/sim/tests/schema-secret-length_test.yaml @@ -13,7 +13,7 @@ tests: postgresql.auth.password: xxxxxxxx asserts: - failedTemplate: - errorPattern: "BETTER_AUTH_SECRET.*minLength" + errorPattern: "BETTER_AUTH_SECRET.*(minLength|greater than or equal to 32)" - it: rejects an ENCRYPTION_KEY shorter than 32 characters set: @@ -24,7 +24,7 @@ tests: postgresql.auth.password: xxxxxxxx asserts: - failedTemplate: - errorPattern: "ENCRYPTION_KEY.*minLength" + errorPattern: "ENCRYPTION_KEY.*(minLength|greater than or equal to 32)" - it: rejects a postgresql.auth.password shorter than 8 characters set: @@ -35,7 +35,7 @@ tests: postgresql.auth.password: short asserts: - failedTemplate: - errorPattern: "password.*minLength" + errorPattern: "password.*(minLength|greater than or equal to 8)" - it: accepts an empty BETTER_AUTH_SECRET (deferred to existingSecret/ESO) templates: diff --git a/helm/sim/tests/smoke_test.yaml b/helm/sim/tests/smoke_test.yaml index 0e50a2799be..23c035c0acf 100644 --- a/helm/sim/tests/smoke_test.yaml +++ b/helm/sim/tests/smoke_test.yaml @@ -4,6 +4,8 @@ templates: - deployment-realtime.yaml - secrets-app.yaml - services.yaml + # referenced by the deployments' checksum/secret annotation + - external-secret-app.yaml release: name: t namespace: sim @@ -31,3 +33,13 @@ tests: asserts: - isKind: { of: Secret } - equal: { path: metadata.name, value: t-sim-app-secrets } + + - it: accepts the standard Helm naming overrides under the strict schema + set: + nameOverride: acme + fullnameOverride: acme-sim + asserts: + - matchRegex: + path: metadata.name + pattern: "^acme-sim" + template: deployment-app.yaml diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index f2dd2007c34..9088f9bd6f1 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -94,10 +94,6 @@ "name": { "type": "string", "description": "Name of the existing Kubernetes secret" - }, - "keys": { - "type": "object", - "description": "Key name mappings in the existing secret" } } } @@ -577,10 +573,6 @@ "name": { "type": "string", "description": "Name of the existing Kubernetes secret" - }, - "passwordKey": { - "type": "string", - "description": "Key in the secret containing the password" } } } @@ -634,10 +626,6 @@ "name": { "type": "string", "description": "Name of the existing Kubernetes secret" - }, - "passwordKey": { - "type": "string", - "description": "Key in the secret containing the password" } } } @@ -1202,8 +1190,81 @@ } } } + }, + "nameOverride": { + "type": "string", + "description": "Override the chart name used in resource names" + }, + "fullnameOverride": { + "type": "string", + "description": "Override the fully qualified release name used in resource names" + }, + "affinity": { + "type": "object", + "description": "Pod affinity/anti-affinity rules applied to app and realtime pods" + }, + "branding": { + "type": "object", + "description": "Whitelabel branding configuration (logos, names, colors)" + }, + "certManager": { + "type": "object", + "description": "Chart-managed cert-manager issuers (self-signed bootstrap + CA) for postgresql.tls" + }, + "cronjobs": { + "type": "object", + "description": "Scheduled jobs (schedule execution, polling, reconciliation)" + }, + "extraEnvVars": { + "type": "array", + "description": "Additional environment variables added to app and realtime containers" + }, + "extraVolumeMounts": { + "type": "array", + "description": "Additional volume mounts added to app and realtime containers" + }, + "extraVolumes": { + "type": "array", + "description": "Additional volumes added to app and realtime pods" + }, + "ingressInternal": { + "type": "object", + "description": "Secondary internal ingress configuration" + }, + "migrations": { + "type": "object", + "description": "Database migrations init-container configuration" + }, + "monitoring": { + "type": "object", + "description": "Prometheus Operator ServiceMonitor configuration" + }, + "networkPolicy": { + "type": "object", + "description": "NetworkPolicy toggles and custom ingress/egress rules" + }, + "podAnnotations": { + "type": "object", + "description": "Annotations added to app and realtime pods" + }, + "podDisruptionBudget": { + "type": "object", + "description": "PodDisruptionBudget configuration for app and realtime" + }, + "podLabels": { + "type": "object", + "description": "Labels added to app and realtime pods" + }, + "serviceAccount": { + "type": "object", + "description": "ServiceAccount creation, name, and annotations (e.g. Workload Identity)" + }, + "tolerations": { + "type": "array", + "description": "Tolerations applied to app and realtime pods" } }, + "additionalProperties": false, "allOf": [ { "if": { diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 543e24f1ffc..5c62ee6def7 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -20,7 +20,7 @@ app: # Image configuration image: repository: simstudioai/simstudio - # tag defaults to Chart.AppVersion. Override with a release tag (e.g. "0.6.73") or pin via image.digest. + # tag defaults to Chart.AppVersion. Override with a release tag (e.g. "v0.7.44") or pin via image.digest. tag: "" # Optional image digest pin: "sha256:..." — when set, overrides tag. digest: "" @@ -61,17 +61,11 @@ app: existingSecret: # Set to true to use an existing secret instead of creating one from values enabled: false - # Name of the existing Kubernetes secret containing app credentials + # Name of the existing Kubernetes secret containing app credentials. + # The secret is consumed wholesale via envFrom, so its keys must use the + # standard names (BETTER_AUTH_SECRET, ENCRYPTION_KEY, INTERNAL_API_SECRET, + # CRON_SECRET, ...) — key remapping is not supported. name: "" - # Key mappings - specify the key names in your existing secret - # Only needed if your secret uses different key names than the defaults - keys: - BETTER_AUTH_SECRET: "BETTER_AUTH_SECRET" - ENCRYPTION_KEY: "ENCRYPTION_KEY" - INTERNAL_API_SECRET: "INTERNAL_API_SECRET" - CRON_SECRET: "CRON_SECRET" - API_ENCRYPTION_KEY: "API_ENCRYPTION_KEY" - REDIS_URL: "REDIS_URL" # Environment variables env: @@ -542,9 +536,9 @@ realtime: extraVolumes: [] extraVolumeMounts: [] -# Database migrations job configuration +# Database migrations configuration (runs as an init container on the app pods) migrations: - # Enable/disable migrations job + # Enable/disable the migrations init container enabled: true # Image configuration @@ -592,8 +586,9 @@ postgresql: # This enables integration with External Secrets Operator, HashiCorp Vault, etc. existingSecret: enabled: false - name: "" # Name of existing Kubernetes secret - passwordKey: "POSTGRES_PASSWORD" # Key in the secret containing the password + name: "" # Name of existing Kubernetes secret. Must contain the + # password under the key POSTGRES_PASSWORD — key + # remapping is not supported. # Node selector for database pod scheduling (leave empty to allow scheduling on any node) nodeSelector: {} @@ -711,8 +706,9 @@ externalDatabase: # This enables integration with External Secrets Operator, HashiCorp Vault, etc. existingSecret: enabled: false - name: "" # Name of existing Kubernetes secret - passwordKey: "EXTERNAL_DB_PASSWORD" # Key in the secret containing the password + name: "" # Name of existing Kubernetes secret. Must contain the + # password under the key EXTERNAL_DB_PASSWORD — key + # remapping is not supported. # Ollama local AI models configuration ollama: @@ -1016,6 +1012,12 @@ serviceAccount: # if you use customMetrics) installed in the cluster. autoscaling: enabled: false + # Also scale the realtime Deployment with the same HPA settings. Multi-replica + # realtime REQUIRES REDIS_URL in app.env (Socket.IO Redis adapter) — without + # Redis, cross-pod collaboration events are silently dropped. Set false to + # keep realtime pinned at realtime.replicaCount while the app still scales. + realtime: + enabled: true minReplicas: 1 maxReplicas: 10 targetCPUUtilizationPercentage: 80 @@ -1435,7 +1437,9 @@ telemetry: # Jaeger tracing backend jaeger: enabled: false - endpoint: "http://jaeger-collector:14250" + # Jaeger's OTLP gRPC endpoint (Jaeger >=1.35 accepts OTLP natively; the + # legacy jaeger exporter was removed from the collector-contrib image) + endpoint: "jaeger-collector:4317" tls: enabled: false @@ -1680,9 +1684,10 @@ copilot: secretKey: DATABASE_URL url: "" - # Migration job configuration + # Migration job configuration (Copilot migrations run as a Helm-hook Job, + # unlike the app migrations which run as an init container) migrations: - # Enable/disable migrations job + # Enable/disable the Copilot migrations Job enabled: true # Image configuration (same as server) @@ -1724,9 +1729,10 @@ externalSecrets: enabled: false # ESO API version. Default "v1beta1" — supported by every ESO release from - # v0.7+ (mid-2023) through current. Set to "v1" only when targeting ESO - # v0.17+ clusters where the v1 API has graduated. - apiVersion: "v1beta1" + # Current ESO releases serve only external-secrets.io/v1 (the v1beta1 + # compatibility path was removed in 2026). Set to "v1beta1" only for + # clusters still running ESO < 0.17. + apiVersion: "v1" # How often to sync secrets from the external store refreshInterval: "1h" diff --git a/package.json b/package.json index ea7fafc1fd4..1689f6e38d5 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "mship:check": "bun run scripts/generate-mship-contracts.ts --check", "skills:sync": "bun run scripts/sync-skills.ts", "skills:check": "bun run scripts/sync-skills.ts --check", + "agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts", + "agent-stream-docs:check": "bun run scripts/sync-agent-stream-docs.ts --check", "prepare": "bun husky", "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts" @@ -68,7 +70,14 @@ "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", - "mermaid": "11.15.0" + "mermaid": "11.15.0", + "zod": "4.3.6" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.11", + "@next/swc-darwin-x64": "16.2.11", + "@next/swc-linux-arm64-gnu": "16.2.11", + "@next/swc-linux-x64-gnu": "16.2.11" }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", @@ -86,6 +95,7 @@ ] }, "trustedDependencies": [ + "isolated-vm", "sharp" ], "dependencies": { diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 90e02afe2af..a3a7e5b3775 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -133,6 +133,9 @@ export const AuditAction = { ORGANIZATION_UPDATED: 'organization.updated', ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked', + ORGANIZATION_DOMAIN_ADDED: 'organization.domain.added', + ORGANIZATION_DOMAIN_VERIFIED: 'organization.domain.verified', + ORGANIZATION_DOMAIN_REMOVED: 'organization.domain.removed', ORG_MEMBER_ADDED: 'org_member.added', ORG_MEMBER_REMOVED: 'org_member.removed', ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', @@ -159,6 +162,8 @@ export const AuditAction = { SKILL_CREATED: 'skill.created', SKILL_UPDATED: 'skill.updated', SKILL_DELETED: 'skill.deleted', + SKILL_MEMBER_ADDED: 'skill_member.added', + SKILL_MEMBER_REMOVED: 'skill_member.removed', // Schedules SCHEDULE_CREATED: 'schedule.created', diff --git a/packages/auth/src/verify.ts b/packages/auth/src/verify.ts index b1e1eb5ccaf..b535da54df6 100644 --- a/packages/auth/src/verify.ts +++ b/packages/auth/src/verify.ts @@ -11,14 +11,48 @@ export interface VerifyAuthOptions { baseURL: string } +/** + * Session payload returned by one-time-token verification. The session row is + * created by `apps/sim`'s full auth config, so it can carry plugin fields + * (e.g. `activeOrganizationId`) this minimal instance does not configure. + */ +export interface VerifiedOneTimeTokenSession { + user: { + id: string + name: string | null + email: string | null + image?: string | null + } + session: { + activeOrganizationId?: string | null + } +} + +/** + * The verification surface consumers use. Declared explicitly (rather than + * inferring Better Auth's instance type) so this package's emitted + * declarations never reference Better Auth's internal zod instance — the + * inferred type is not portable across install layouts (TS2883). + */ +export interface VerifyAuth { + api: { + verifyOneTimeToken: (input: { + body: { token: string } + }) => Promise + } +} + /** * Minimal Better Auth instance used by services that only need to verify * one-time tokens issued by the main app. Shares the Better Auth DB schema * (`verification` table) and secret with the main app, so tokens issued by - * `apps/sim`'s full auth config are accepted here. + * `apps/sim`'s full auth config are accepted here. The instance is wrapped in + * the {@link VerifyAuth} contract rather than returned directly so consumers + * (and this package's declaration output) never depend on Better Auth's + * inferred endpoint types. */ -export function createVerifyAuth(options: VerifyAuthOptions) { - return betterAuth({ +export function createVerifyAuth(options: VerifyAuthOptions): VerifyAuth { + const auth = betterAuth({ baseURL: options.baseURL, secret: options.secret, database: drizzleAdapter(db, { @@ -31,6 +65,27 @@ export function createVerifyAuth(options: VerifyAuthOptions) { }), ], }) -} -export type VerifyAuth = ReturnType + return { + api: { + verifyOneTimeToken: async (input) => { + const result = await auth.api.verifyOneTimeToken(input) + if (!result?.user?.id) return null + return { + user: { + id: result.user.id, + name: result.user.name ?? null, + email: result.user.email ?? null, + image: result.user.image ?? null, + }, + session: { + activeOrganizationId: + typeof result.session.activeOrganizationId === 'string' + ? result.session.activeOrganizationId + : null, + }, + } + }, + }, + } +} diff --git a/packages/db/migrations/0266_spotty_alex_power.sql b/packages/db/migrations/0266_spotty_alex_power.sql new file mode 100644 index 00000000000..1ddc7cfb989 --- /dev/null +++ b/packages/db/migrations/0266_spotty_alex_power.sql @@ -0,0 +1 @@ +ALTER TABLE "chat" ADD COLUMN IF NOT EXISTS "include_thinking" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/0267_dark_romulus.sql b/packages/db/migrations/0267_dark_romulus.sql new file mode 100644 index 00000000000..b75860a6ee7 --- /dev/null +++ b/packages/db/migrations/0267_dark_romulus.sql @@ -0,0 +1,29 @@ +CREATE TABLE "skill_member" ( + "id" text PRIMARY KEY NOT NULL, + "skill_id" text NOT NULL, + "user_id" text NOT NULL, + "invited_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "skill_member" ADD CONSTRAINT "skill_member_skill_id_skill_id_fk" FOREIGN KEY ("skill_id") REFERENCES "public"."skill"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "skill_member" ADD CONSTRAINT "skill_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "skill_member" ADD CONSTRAINT "skill_member_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "skill_member_user_id_idx" ON "skill_member" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "skill_member_unique" ON "skill_member" USING btree ("skill_id","user_id");--> statement-breakpoint +-- Backfill: existing skills predate the editors list, where any workspace `write` user +-- could edit any skill. Grant those users an explicit editor row so their edit rights +-- survive the cutover. Workspace admins get no rows (they are derived editors at +-- runtime), which the write-only permission join guarantees — the permissions table is +-- unique on (user_id, entity_type, entity_id). Everyone with workspace access can see +-- and use every skill regardless of rows, so no other grants are needed. +-- Idempotent and deploy-window re-runnable: deterministic ids + ON CONFLICT DO NOTHING, +-- so re-running this INSERT once after full cutover heals any skill created by a +-- still-running old pod during the deploy window. +INSERT INTO "skill_member" ("id", "skill_id", "user_id", "invited_by", "created_at", "updated_at") +SELECT 'skillm_' || md5(s."id" || ':' || p."user_id"), s."id", p."user_id", s."user_id", now(), now() +FROM "skill" s +INNER JOIN "permissions" p ON p."entity_type" = 'workspace' AND p."entity_id" = s."workspace_id" AND p."permission_type" = 'write' +WHERE s."workspace_id" IS NOT NULL +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/packages/db/migrations/0268_sso_domain_verification.sql b/packages/db/migrations/0268_sso_domain_verification.sql new file mode 100644 index 00000000000..2e839b907d6 --- /dev/null +++ b/packages/db/migrations/0268_sso_domain_verification.sql @@ -0,0 +1,48 @@ +CREATE TABLE "sso_domain" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "domain" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "verification_token" text NOT NULL, + "verified_at" timestamp, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "sso_domain" ADD CONSTRAINT "sso_domain_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sso_domain" ADD CONSTRAINT "sso_domain_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "sso_domain_organization_id_idx" ON "sso_domain" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "sso_domain_domain_idx" ON "sso_domain" USING btree ("domain");--> statement-breakpoint +CREATE UNIQUE INDEX "sso_domain_org_domain_unique" ON "sso_domain" USING btree ("organization_id","domain");--> statement-breakpoint +CREATE UNIQUE INDEX "sso_domain_verified_unique" ON "sso_domain" USING btree ("domain") WHERE status = 'verified';--> statement-breakpoint +-- Grandfather existing org-scoped SSO provider domains as verified: they were +-- claimed under the old first-come model, so treat them as owned/verified to +-- avoid breaking live SSO. The normalized value must match normalizeSSODomain +-- (the runtime gate's canonicalizer) so grandfathered lookups hit — hence +-- lower + trim + strip a leading wildcard label; other artifacts (protocol, +-- port, path) never occur in stored SSO domains. Computing it in a subquery +-- keeps the DISTINCT ON dedup key identical to the inserted value, so the +-- global partial unique index on verified rows can never be violated. The +-- token is a placeholder since these rows are already verified. +-- NOTE: if two orgs somehow share a domain (possible only for data predating the +-- register-route conflict check), DISTINCT ON grandfathers the lowest org_id and +-- the other org gets no row. Its existing SSO login is unaffected (login never +-- reads sso_domain); it just can't re-register that domain until an admin +-- resolves the duplicate. Validated against prod: no such duplicates exist. +INSERT INTO "sso_domain" ("id", "organization_id", "domain", "status", "verification_token", "verified_at", "created_at", "updated_at") +SELECT DISTINCT ON ("norm_domain") + gen_random_uuid()::text, + "organization_id", + "norm_domain", + 'verified', + gen_random_uuid()::text, + now(), + now(), + now() +FROM ( + SELECT "organization_id", lower(regexp_replace(btrim("domain"), '^\*\.', '')) AS "norm_domain" + FROM "sso_provider" + WHERE "organization_id" IS NOT NULL +) AS "normalized" +ORDER BY "norm_domain", "organization_id"; \ No newline at end of file diff --git a/packages/db/migrations/0269_late_spencer_smythe.sql b/packages/db/migrations/0269_late_spencer_smythe.sql new file mode 100644 index 00000000000..a763e805791 --- /dev/null +++ b/packages/db/migrations/0269_late_spencer_smythe.sql @@ -0,0 +1 @@ +ALTER TABLE "chat" ADD COLUMN "include_tool_calls" boolean; \ No newline at end of file diff --git a/packages/db/migrations/meta/0266_snapshot.json b/packages/db/migrations/meta/0266_snapshot.json new file mode 100644 index 00000000000..5e04c786467 --- /dev/null +++ b/packages/db/migrations/meta/0266_snapshot.json @@ -0,0 +1,17413 @@ +{ + "id": "dd2e88e1-ce36-426e-9857-a9f8ab6141fb", + "prevId": "18059bad-80c8-4739-a925-3883824a5cd2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0267_snapshot.json b/packages/db/migrations/meta/0267_snapshot.json new file mode 100644 index 00000000000..4be63a659fd --- /dev/null +++ b/packages/db/migrations/meta/0267_snapshot.json @@ -0,0 +1,17529 @@ +{ + "id": "25435972-1fe1-4766-a728-d8e5c8818168", + "prevId": "dd2e88e1-ce36-426e-9857-a9f8ab6141fb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0268_snapshot.json b/packages/db/migrations/meta/0268_snapshot.json new file mode 100644 index 00000000000..f4e52b58bcf --- /dev/null +++ b/packages/db/migrations/meta/0268_snapshot.json @@ -0,0 +1,17686 @@ +{ + "id": "e341f9c4-ac9a-4386-b6a6-4dfb31b952dd", + "prevId": "25435972-1fe1-4766-a728-d8e5c8818168", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0269_snapshot.json b/packages/db/migrations/meta/0269_snapshot.json new file mode 100644 index 00000000000..c0dd5014352 --- /dev/null +++ b/packages/db/migrations/meta/0269_snapshot.json @@ -0,0 +1,17692 @@ +{ + "id": "dea711e3-b8c7-4279-bb8a-353bdaffdfe8", + "prevId": "e341f9c4-ac9a-4386-b6a6-4dfb31b952dd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 54732eb975f..cde0fe20a26 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1856,6 +1856,34 @@ "when": 1784756534601, "tag": "0265_org_session_policy", "breakpoints": true + }, + { + "idx": 266, + "version": "7", + "when": 1784838122475, + "tag": "0266_spotty_alex_power", + "breakpoints": true + }, + { + "idx": 267, + "version": "7", + "when": 1784861110986, + "tag": "0267_dark_romulus", + "breakpoints": true + }, + { + "idx": 268, + "version": "7", + "when": 1784861741441, + "tag": "0268_sso_domain_verification", + "breakpoints": true + }, + { + "idx": 269, + "version": "7", + "when": 1784911198876, + "tag": "0269_late_spencer_smythe", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 083cdb72e72..a875f02059c 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1050,6 +1050,31 @@ export const skill = pgTable( }) ) +/** + * Editor grants for a skill. A row makes the user an editor (edit, delete, + * share); workspace admins are derived editors and need no rows. Everyone with + * workspace access can see and use every skill regardless of rows. + */ +export const skillMember = pgTable( + 'skill_member', + { + id: text('id').primaryKey(), + skillId: text('skill_id') + .notNull() + .references(() => skill.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + userIdIdx: index('skill_member_user_id_idx').on(table.userId), + uniqueMembership: uniqueIndex('skill_member_unique').on(table.skillId, table.userId), + }) +) + export const mothershipSettings = pgTable( 'mothership_settings', { @@ -1132,6 +1157,21 @@ export const chat = pgTable( // Output configuration outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects + /** + * When true, public chat SSE may expose provider thinking events if the + * client also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. + * Default off — never derived from auth type or isSecureMode. + */ + includeThinking: boolean('include_thinking').notNull().default(false), + /** + * When true, public chat SSE may expose tool lifecycle events if the client + * also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. + * + * Null preserves the pre-expand policy: readers fall back to includeThinking. + */ + // contract-pending(after the includeToolCalls expand release is fully deployed): backfill include_tool_calls from include_thinking, then set DEFAULT false and NOT NULL — all new-app chat writes persist an explicit value + includeToolCalls: boolean('include_tool_calls'), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), @@ -3001,6 +3041,57 @@ export const ssoProvider = pgTable( }) ) +/** + * An email domain an organization has claimed, and its verification state. + * + * A domain must be **verified** (via a DNS TXT challenge — the org places + * `verificationToken` in a `_sim-challenge.` record) before it can be + * configured for single sign-on: verifying proves the org controls the domain, + * which is the security precondition for wiring it to an identity provider. + * Existing `sso_provider` domains are grandfathered as `verified` by the + * backfill in migration 0266. + */ +export const ssoDomain = pgTable( + 'sso_domain', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + /** Normalized (lowercase, registrable) domain — see `normalizeSSODomain`. */ + domain: text('domain').notNull(), + /** `'pending'` until the DNS TXT record is observed, then `'verified'`. */ + status: text('status').notNull().default('pending'), + /** High-entropy token placed in the domain's `_sim-challenge` TXT record. */ + verificationToken: text('verification_token').notNull(), + verifiedAt: timestamp('verified_at'), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + organizationIdIdx: index('sso_domain_organization_id_idx').on(table.organizationId), + domainIdx: index('sso_domain_domain_idx').on(table.domain), + /** + * An org holds at most one row per domain. Makes claims idempotent under + * concurrency: two admins racing to add the same domain cannot create + * duplicate pending rows — the second insert hits this constraint. + */ + orgDomainUnique: uniqueIndex('sso_domain_org_domain_unique').on( + table.organizationId, + table.domain + ), + /** + * A verified domain is globally unique — exactly one org owns it. Pending + * rows may coexist (multiple orgs can race to prove ownership), so the + * constraint is a partial unique index scoped to verified rows. + */ + verifiedDomainUnique: uniqueIndex('sso_domain_verified_unique') + .on(table.domain) + .where(sql`status = 'verified'`), + }) +) + /** * Workflow MCP Servers - User-created MCP servers that expose workflows as tools. * These servers are accessible by external MCP clients via API key authentication, diff --git a/packages/db/scripts/register-sso-provider.ts b/packages/db/scripts/register-sso-provider.ts index a52c6fa45e1..a5f2ecf61ec 100644 --- a/packages/db/scripts/register-sso-provider.ts +++ b/packages/db/scripts/register-sso-provider.ts @@ -40,10 +40,11 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { and, eq } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { ssoProvider, user } from '../schema' +import { ssoDomain, ssoProvider, user } from '../schema' interface SSOMapping { id: string @@ -561,7 +562,7 @@ async function registerSSOProvider(): Promise { } const existingProviders = await db - .select() + .select({ id: ssoProvider.id }) .from(ssoProvider) .where(eq(ssoProvider.providerId, ssoConfig.providerId)) @@ -625,21 +626,69 @@ async function registerSSOProvider(): Promise { providerData.samlConfig = JSON.stringify(samlConfig) } - if (existingProviders.length > 0) { - await db - .update(ssoProvider) - .set({ - issuer: providerData.issuer, - domain: providerData.domain, - oidcConfig: providerData.oidcConfig, - samlConfig: providerData.samlConfig, - userId: providerData.userId, - organizationId: providerData.organizationId, - }) - .where(eq(ssoProvider.providerId, ssoConfig.providerId)) - } else { - await db.insert(ssoProvider).values(providerData) - } + // Write the provider and its verified-domain ownership record atomically so + // a failed domain upsert (e.g. the domain is already owned by another org) + // can never leave a live provider without its ownership row. Both commit or + // neither does. + await db.transaction(async (tx) => { + // Idempotent, race-safe upsert for a providerId that has NO unique + // constraint (sso_provider.provider_id is a plain index, and prod already + // holds legitimate duplicates): delete every row for this providerId, then + // insert exactly one. A prior "update by id, else insert" could create a + // duplicate when the observed row was deregistered and replaced before the + // transaction — with no unique constraint the fallback insert would + // succeed. Delete-then-insert inside the transaction guarantees the + // providerId ends up as exactly this config, atomically. Deleting the + // ssoProvider row does not touch linked accounts (they key on the + // providerId string, not the row id), so existing SSO logins keep working. + await tx.delete(ssoProvider).where(eq(ssoProvider.providerId, ssoConfig.providerId)) + await tx.insert(ssoProvider).values(providerData) + + // Keep the verified-domains model consistent with script registration: a + // domain registered for org SSO here is owned by that org, so mark it + // verified (mirrors migration 0266's grandfathering). Without this, a + // domain added by the script after the migration would be missing its + // verified record and the UI/API would treat it as unverified. Org-scoped + // only — user-scoped providers have no org to attach a domain to. + // Canonicalize with the SAME shared normalizer the runtime gate uses, so a + // script-created ownership row keys on the exact value the gate looks up + // (no divergence for protocol/port/path/@/trailing-dot spellings). + const normalizedDomain = providerData.organizationId + ? normalizeSSODomain(ssoConfig.domain) + : null + if (providerData.organizationId && !normalizedDomain) { + logger.warn( + 'Skipping verified-domain record: SSO_DOMAIN is not a valid registrable domain', + { domain: ssoConfig.domain } + ) + } + if (providerData.organizationId && normalizedDomain) { + const existingDomain = await tx + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where( + and( + eq(ssoDomain.organizationId, providerData.organizationId), + eq(ssoDomain.domain, normalizedDomain) + ) + ) + if (existingDomain.length === 0) { + await tx.insert(ssoDomain).values({ + id: generateId(), + organizationId: providerData.organizationId, + domain: normalizedDomain, + status: 'verified', + verificationToken: generateId(), + verifiedAt: new Date(), + }) + } else { + await tx + .update(ssoDomain) + .set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(ssoDomain.id, existingDomain[0].id)) + } + } + }) logger.info('✅ SSO provider registered successfully in database!', { providerId: ssoConfig.providerId, diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index 244b09b6585..67c4b7a8b02 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -71,6 +71,20 @@ interface ChipDropdownBaseProps extends VariantProps { leftIcon?: ChipIcon /** Forwarded class for the trigger button. */ className?: string + /** + * Accessible name for the trigger. Use when the visible label sits outside + * the component (a field label above it) rather than in the selected value. + */ + 'aria-label'?: string + /** + * Ids of the elements naming the trigger. Because `aria-labelledby` REPLACES + * the name derived from the trigger's contents, include the trigger's own id + * alongside the external label's — `\`${labelId} ${triggerId}\`` — or the + * selected value is dropped from the accessible name. + */ + 'aria-labelledby'?: string + /** Id for the trigger button. Needed to reference it from `aria-labelledby`. */ + id?: string } /** @@ -168,6 +182,9 @@ const ChipDropdown = forwardRef( active, fullWidth, flush, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + id, } = props const isMultiple = props.multiple === true @@ -285,8 +302,11 @@ const ChipDropdown = forwardRef(