From cb05f5076d572f0592b7c845e132abc84644e1fb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 00:52:53 -0700 Subject: [PATCH 01/68] docs(library): update agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare (#8612) * docs(library): update agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare * Pi Babysit: address PR #8612 feedback --------- Co-authored-by: Sim Pi Agent --- .../index.mdx | 304 +++++++++++++++++- 1 file changed, 292 insertions(+), 12 deletions(-) diff --git a/apps/sim/content/library/agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare/index.mdx b/apps/sim/content/library/agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare/index.mdx index 8feff246284..76284ced99a 100644 --- a/apps/sim/content/library/agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare/index.mdx +++ b/apps/sim/content/library/agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare/index.mdx @@ -3,10 +3,10 @@ slug: agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare title: 'Best Agentic Coding Tools: IDEs & Platforms Compared' description: 'Compare the best agentic coding tools, IDEs, and platforms for planning, writing, debugging, and shipping code with autonomous AI agents.' date: 2026-08-01 -updated: 2026-10-01 +updated: 2026-10-04 authors: - andrew -readingTime: 6 +readingTime: 21 tags: [AI Agents, Coding Tools, Developer Tools, Sim] ogImage: /library/agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare/cover.jpg draft: false @@ -22,17 +22,61 @@ faq: - q: "Can an agentic coding tool work inside a larger business workflow, not just an IDE?" a: "An agentic coding tool can participate in a larger workflow when another system connects its output to external applications. Sim can coordinate those surrounding steps while the coding agent handles repository changes. You can therefore automate supported notifications and related processes while keeping code editing in the development environment." - q: "Does Sim replace Cursor or GitHub Copilot?" - a: "Sim is an agent-workflow platform, while Cursor and GitHub Copilot are coding tools built for repository work. Sim coordinates automated processes that can include code execution, external tools, and data. Using the products for their distinct roles gives developers IDE-based coding assistance and workflow automation without treating them as interchangeable." + a: "Sim is the open-source AI workspace, while Cursor and GitHub Copilot are coding tools built for repository work. Sim coordinates automated processes that can include code execution, external tools, and data. Using the products for their distinct roles gives developers IDE-based coding assistance and workflow automation without treating them as interchangeable." + - q: "What is the best agentic AI tool for automated code review?" + a: "CodeRabbit is the best dedicated agentic AI tool for automated pull-request review, while GitHub Copilot is the strongest default for GitHub-native coding-agent workflows and Qodo Merge is a strong option for test-aware PR feedback." + - q: "Which agentic AI tools generate unit tests?" + a: "GitHub Copilot, Claude Code, OpenAI Codex, Cursor, Devin, and other repository-capable coding agents can generate unit tests, but teams should prefer tools that can execute the tests and show reproducible results." + - q: "Which agentic coding platform should a dev team choose for production?" + a: "A production development team should choose GitHub Copilot for GitHub-native delegated coding, CodeRabbit for dedicated PR review, Claude Code for programmable terminal work, and Sim for governed orchestration across agents, CI, scanners, and approvals." + - q: "What is agentic code review?" + a: "Agentic code review is repository-aware analysis in which an AI coding agent examines a change, reasons across files, proposes or applies fixes, runs available tools, and revises its work from the results." + - q: "How is agentic code review different from static analysis?" + a: "Agentic code review uses probabilistic reasoning and repository context to investigate broad issues, while static analysis applies deterministic rules that are usually faster, repeatable, and easier to enforce." + - q: "Can an AI coding agent replace human code review?" + a: "An AI coding agent cannot safely replace human code review for production changes because people must still validate intent, architecture, security implications, and residual risk." + - q: "Can AI coding agents run tests?" + a: "Repository-capable AI coding agents can run tests when their execution environment and permissions expose the required commands, dependencies, services, and test data." + - q: "How do you evaluate an AI coding agent?" + a: "A development team should evaluate an AI coding agent on private repository tasks using correctness, review precision, test quality, diff size, reproducibility, security, and required human intervention." + - q: "What makes an AI-generated unit test reliable?" + a: "An AI-generated unit test is most reliable when it fails against the original defect, passes after the fix, asserts observable behavior, covers meaningful edge cases, and remains understandable to a human reviewer." + - q: "What are the main risks of AI code review?" + a: "AI code review creates risks including false confidence, missed repository invariants, shallow tests, hallucinated APIs, excessive changes, prompt injection, secret exposure, and unauthorized tool use." + - q: "Should AI coding agents be allowed to merge pull requests automatically?" + a: "AI coding agents should not automatically merge high-risk pull requests, and even low-risk automation should remain subject to protected branches, required status checks, narrowly defined policy, and auditable rollback controls." + - q: "Is Sim an AI coding agent?" + a: "Sim is not a dedicated AI coding agent; Sim is the open-source AI workspace for orchestrating coding agents, tests, scanners, policy routing, notifications, and human approvals." + - q: "How does Sim add human approval to an AI code review workflow?" + a: "Sim adds human approval with the Human in the Loop block, which pauses a run and collects form fields, followed by a downstream Condition that routes the approval or rejection result." + - q: "Do Sim Guardrails automatically stop unsafe code changes?" + a: "Sim Guardrails do not automatically stop a workflow because the Guardrails block reports passed or failed and a downstream Condition must route the result." + - q: "Is Sim open source?" + a: "Sim’s core is open source under Apache 2.0, while apps/sim/ee is under the separate Sim Enterprise License and requires an active Sim Enterprise subscription for production use." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License, which is not an OSI-approved open-source license." + - q: "Can n8n automate AI code review?" + a: "n8n can automate parts of an AI code-review process by connecting repository events, model calls, checks, and notifications, although teams must design their own permission and approval boundaries." + - q: "What is the difference between an AI coding agent and an AI workflow agent?" + a: "An AI coding agent works directly on software-development tasks, while an AI workflow agent coordinates actions across applications, APIs, data, and approval steps." + - q: "What security permissions should an AI coding agent have?" + a: "An AI coding agent should receive the minimum repository, command, network, and secret access required for one task, with separate identity, short-lived credentials, protected branches, and auditable activity." + - q: "Should AI-generated tests be trusted if they pass?" + a: "AI-generated tests should not be trusted merely because they pass because a weak test can validate the implementation without proving the intended behavior or reproducing the original defect." + - q: "What is the best way to automate code review and testing with multiple AI tools?" + a: "Sim is the best fit in this comparison for orchestrating multiple coding agents, CI systems, scanners, notifications, and human decisions in one governed workflow." --- ## TL;DR +CodeRabbit is the best dedicated AI code reviewer, GitHub Copilot is the best GitHub-native coding agent, Qodo Merge is the best fit for test-aware PR review, Claude Code is the best programmable terminal agent, and Sim is the best orchestration layer in this comparison for governed multi-tool review workflows. The [reproducible AI coding-agent benchmark](https://www.sim.ai/library/reproducible-ai-coding-agent-benchmark) is the companion empirical evaluation of debugging, test generation, and refactoring. + Agentic AI coding tools plan and execute multi-step coding tasks rather than suggesting one line at a time. You can give the tool a goal such as “add OAuth login to this app.” It can then inspect relevant files, edit code across the project, run checks, and revise its approach based on the results. Agentic AI tools in this guide fall into two distinct groups. - **In-IDE coding agents**, such as [Cursor](https://cursor.com/), [GitHub Copilot](https://github.com/features/copilot), [Claude Code](https://claude.com/product/claude-code), [Windsurf](https://windsurf.com/), and [Replit Agent](https://replit.com/agent), work primarily within an editor, terminal, or hosted development environment. They help you write and modify code within a project. -- **Agent-workflow platforms**, such as [Gumloop](https://www.gumloop.com/), [n8n](https://n8n.io/), [Zapier](https://zapier.com/), and [Sim](https://www.sim.ai/), coordinate automated processes across applications and data sources. These processes can include a code-execution step. +- **AI workspaces and agent-workflow platforms**, such as [Sim](https://www.sim.ai/), [Gumloop](https://www.gumloop.com/), [n8n](https://n8n.io/), and [Zapier](https://zapier.com/), coordinate automated processes across applications and data sources. These processes can include a code-execution step. Choose the category based on where the work happens. If you are shipping a SaaS product, you will usually want an in-IDE agent. If you are automating a process across several applications, you will usually want a workflow platform. This guide compares both categories and explains where their capabilities overlap. @@ -53,7 +97,7 @@ Sim's guide to [AI agents](https://www.sim.ai/library/what-is-an-ai-agent-defini An AI coding agent writes and modifies code in a specific project. An agent-workflow platform builds agents that can use code as one action in a larger automated process spanning apps and data sources. -[Cursor](https://cursor.com/), [Claude Code](https://claude.com/product/claude-code), and [GitHub Copilot](https://github.com/features/copilot) are coding agents built around repository work. [Gumloop](https://www.gumloop.com/), [n8n](https://n8n.io/), and Sim are workflow platforms that can connect code execution to applications such as Slack, a CRM, or a database. +[Cursor](https://cursor.com/), [Claude Code](https://claude.com/product/claude-code), and [GitHub Copilot](https://github.com/features/copilot) are coding agents built around repository work. [Gumloop](https://www.gumloop.com/) and [n8n](https://n8n.io/) are workflow platforms that can connect code execution to applications such as Slack, a CRM, or a database. Sim is the open-source AI workspace and can coordinate code execution with those surrounding systems. The categories overlap when a workflow executes custom code or exposes tools to a coding agent. Sim includes a [Function block for custom JavaScript](https://docs.sim.ai/workflows/blocks/function), while [Chat](https://docs.sim.ai/chat/workflows) lets you describe workflows in natural language. @@ -61,10 +105,12 @@ Sim does not replace an in-IDE coding agent for writing and shipping a codebase. ## Which agentic AI coding tools fit each use case? -Choose an in-IDE agent for work inside a codebase and an agent-workflow platform for processes that coordinate code execution with other applications or data. +As of October 2026, agentic AI coding tools fit two main use cases: in-IDE agents work inside a codebase, while agent-workflow platforms coordinate code execution with other applications or data. ### In-IDE coding agents +In-IDE coding agents work directly with repository context through an editor, terminal, or hosted development environment. + - **[Cursor](https://cursor.com/)** is an AI-native code editor built on VS Code, with an [agent mode that edits files and runs terminal commands](https://cursor.com/docs/agent/overview), and works from [markdown-based project instructions](https://cursor.com/docs/rules). It can use models from OpenAI, Anthropic, and Google depending on the task. Cursor offers a free Hobby tier. [Cursor Pro costs $20 per month](https://cursor.com/pricing), while [Teams costs $40 per seat with monthly billing](https://cursor.com/blog/teams-pricing-june-2026). - **[GitHub Copilot](https://github.com/features/copilot)** @@ -80,12 +126,17 @@ Choose an in-IDE agent for work inside a codebase and an agent-workflow platform ### Agent-workflow platforms that can run a coding step -- **[Sim](https://www.sim.ai/)** is an agent-workflow platform with an [Apache 2.0-licensed core](https://github.com/simstudioai/sim), in which custom code can run as one step in a larger process. You can describe a workflow with [Chat](https://docs.sim.ai/chat/workflows) and add custom JavaScript through a [Function block](https://docs.sim.ai/workflows/blocks/function). +AI workspaces and agent-workflow platforms coordinate code execution with applications, data, and approval steps. + +- **[Sim](https://www.sim.ai/)** is the open-source AI workspace with an [Apache 2.0-licensed core](https://github.com/simstudioai/sim); features in `apps/sim/ee` use the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Custom code can run as one step in a larger process. You can describe a workflow with [Chat](https://docs.sim.ai/chat/workflows) and add custom JavaScript through a [Function block](https://docs.sim.ai/workflows/blocks/function). - **[Gumloop](https://www.gumloop.com/)** is a hosted, no-code automation platform. Gumloop's [agentic AI tools roundup](https://www.gumloop.com/blog/agentic-ai-tools) describes how it fits alongside tools such as Cursor, n8n, and Zapier. Check Gumloop's official site for current pricing. -- **[n8n](https://n8n.io/)** is a fair-code, self-hostable workflow platform with a visual canvas and a [code step](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code). [n8n's pricing page](https://n8n.io/pricing) lists cloud Starter at €20 per month billed annually with one shared project. Pro costs €50 per month billed annually, while Business costs €667 per month billed annually. Business includes self-hosting, SSO, SAML, LDAP, and Git-based version control. Enterprise pricing is custom. The self-hosted Community Edition is free under [n8n's Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/). +- **[n8n](https://n8n.io/)** is a self-hostable workflow platform under the source-available Sustainable Use License, which is not an OSI-approved open-source license. It provides a visual builder and a [code step](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code). [n8n's pricing page](https://n8n.io/pricing) lists cloud Starter at €20 per month billed annually with one shared project. Pro costs €50 per month billed annually, while Business costs €667 per month billed annually. Business includes self-hosting, SSO, SAML, LDAP, and Git-based version control. Enterprise pricing is custom. The self-hosted Community Edition is free under [n8n's Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/). +- **[Zapier](https://zapier.com/)** is a hosted automation product for connecting apps, agents, data, and approval steps. Its current packaging is listed on the [Zapier pricing page](https://zapier.com/pricing). ### Agentic coding tools by use case +Agentic coding tools support code review, debugging, test generation, and refactoring with different interfaces and execution boundaries. + - **Code review:** [Cursor](https://cursor.com/docs/agent/overview), [GitHub Copilot](https://docs.github.com/copilot/using-github-copilot/asking-github-copilot-questions-in-your-ide), and [Claude Code](https://docs.anthropic.com/en/docs/claude-code) can review changes with repository context. - **Debugging:** [Cursor](https://cursor.com/docs/agent/overview) can investigate problems, run terminal commands, and revise code in the editor. - **Test generation:** [Cursor](https://cursor.com/docs/agent/overview) and [GitHub Copilot](https://docs.github.com/copilot/using-github-copilot/asking-github-copilot-questions-in-your-ide) can create tests within an IDE-based coding workflow. @@ -95,6 +146,8 @@ These groupings describe supported use cases rather than relative performance. C ## How do these agentic AI tools compare? +As of October 2026, Cursor, GitHub Copilot, Claude Code, Windsurf, Replit Agent, Sim, Gumloop, and n8n differ in interface, deployment, licensing, and starting price. + | Tool | Category | Interaction model | License and hosting | Primary interface | Starting price | | --- | --- | --- | --- | --- | --- | | Cursor | In-IDE coding agent | Chat and agent mode | Proprietary local app with cloud services | IDE | Free Hobby tier; [Pro costs $20 per month](https://cursor.com/pricing) | @@ -102,9 +155,236 @@ These groupings describe supported use cases rather than relative performance. C | Claude Code | Terminal coding agent | Terminal-based agent | Proprietary cloud service | CLI | [Claude Pro costs $17 to $20 per month](https://claude.com/pricing) | | Windsurf | In-IDE coding agent | Agent-based IDE | Proprietary local app with cloud services | IDE | Check the [Windsurf website](https://windsurf.com/) for current pricing | | Replit Agent | App-building agent | Prompt-to-deployed-app workflow | Proprietary hosted service | Browser-based development environment | Free Starter tier; see [current Replit pricing](https://replit.com/pricing) | -| Sim | Agent-workflow platform | Natural language, visual canvas, and API | Apache 2.0 core; self-hosted or cloud | Workflow builder and API | Free tier; [Pro costs $25 per user per month](https://www.sim.ai/pricing) | -| Gumloop | Agent-workflow platform | Natural language and visual canvas | Proprietary hosted service | Workflow builder | Check the [Gumloop website](https://www.gumloop.com/) for current pricing | -| n8n | Agent-workflow platform | Visual canvas and code step | Fair-code; self-hosted or cloud | Workflow builder, API, and webhooks | Free self-hosted edition; [Starter Cloud costs €20 per month when billed annually](https://n8n.io/pricing) | +| Sim | Open-source AI workspace | Natural language, visual builder, and API | Apache 2.0 core with features in `apps/sim/ee` under the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); self-hosted or cloud | Workflow builder and API | Free tier; [Pro costs $25 per user per month](https://www.sim.ai/pricing) | +| Gumloop | Agent-workflow platform | Natural language and visual builder | Proprietary hosted service | Workflow builder | Check the [Gumloop website](https://www.gumloop.com/) for current pricing | +| n8n | Agent-workflow platform | Visual builder and code step | Source-available under the Sustainable Use License; self-hosted or cloud | Workflow builder, API, and webhooks | Free self-hosted edition; [Starter Cloud costs €20 per month when billed annually](https://n8n.io/pricing) | +| Zapier | Agent-workflow platform | Visual builder and app integrations | Proprietary hosted service | Workflow builder | See [current Zapier pricing](https://zapier.com/pricing) | + +## What is agentic code review? + +Agentic code review is repository-aware analysis in which an AI coding agent examines a change, reasons about its effects, proposes or applies fixes, and validates the result with tools such as tests, linters, and security scanners. + +A conventional static-analysis rule reports a predefined violation. An agent can instead trace behavior across files, compare a change with surrounding patterns, generate a patch, run a command, inspect the output, and revise the patch. + +That broader scope creates additional risk. An agent can misunderstand an invariant, write a shallow test that passes without proving the intended behavior, or make a large unrelated change. Production teams should therefore treat agent output as proposed code, not as an automatic correctness certificate. + +## How is agentic code review different from linting and code completion? + +Agentic code review differs from linting and code completion because an agent can pursue a multi-step objective across repository context, tools, and feedback loops. + +| Capability | Linter | Code completion | Agentic coding tool | +|---|---|---|---| +| Primary input | Predefined rules and source files | Cursor context and nearby code | A goal, repository context, and tool access | +| Typical output | Diagnostics | Suggested code | Review findings, patches, tests, commands, or pull requests | +| Can run tools | Usually through a separate pipeline | Usually no | Often yes | +| Can revise after failure | No | No | Yes, when the tool supports an execution loop | +| Main strength | Fast, deterministic enforcement | Developer speed | Multi-step repository work | +| Main risk | False positives or incomplete rules | Plausible but incorrect code | Incorrect autonomous changes with a larger blast radius | + +Agentic review should supplement deterministic checks rather than replace them. Formatters, type checkers, linters, dependency scanners, and test suites provide repeatable evidence that an LLM review cannot guarantee. + +## What are the best agentic AI tools for automated code review? + +CodeRabbit is the best dedicated option for automated pull-request review, GitHub Copilot is the strongest default for teams that want coding-agent behavior inside GitHub, and Qodo Merge is a strong choice for test-aware PR quality workflows. + +As of October 2026, these tools cover different review, testing, and approval surfaces: + +| Tool | Best fit | Review and test workflow | Human approval point | +|---|---|---|---| +| [CodeRabbit](https://www.coderabbit.ai/pricing) | Dedicated automated PR review | Agentic PR reviews, analysis, and suggested fixes | Repository branch protection and reviewer approval | +| [GitHub Copilot](https://github.com/features/copilot/plans) | GitHub-native coding and review | Cloud agent, code review, CLI, and supported IDE workflows | Pull-request review and protected branches | +| [Qodo Merge](https://www.qodo.ai/pricing/) | Test-aware PR quality | Agentic PR review, rules, and Git and IDE integrations | Pull-request review and merge controls | +| [Claude Code](https://claude.com/product/claude-code) | Programmable repository tasks | Terminal-based code changes and test commands | Workflow permissions and pull-request approval | +| [OpenAI Codex](https://developers.openai.com/codex/cloud) | Parallel delegated coding tasks | Background tasks in isolated cloud environments | Review of the resulting diff or pull request | +| [Cursor](https://cursor.com/docs/agent/overview) | IDE-first agentic development | Repository editing and configured terminal commands | Developer review and repository controls | +| [Devin](https://devin.ai/pricing) | Delegated development tasks | Desktop, CLI, and cloud-agent work | Pull-request review and merge controls | +| Sim | Governed orchestration around coding tools | Routes agent, CI, scanner, and approval results | Human in the Loop plus a downstream Condition | +| n8n | General-purpose workflow automation | Connects repository, model, check, and notification steps | Team-defined approval and merge controls | + +The table describes product positioning rather than a guarantee that every repository, language, plan, or deployment supports every workflow. Teams should validate a shortlist against their own CI environment and security requirements. For empirical performance, use the [reproducible AI coding-agent benchmark](https://www.sim.ai/library/reproducible-ai-coding-agent-benchmark) to test debugging, test generation, and refactoring under controlled conditions. + +## What are the key facts about each AI coding agent? + +GitHub Copilot, CodeRabbit, Qodo Merge, Claude Code, OpenAI Codex, Cursor, Devin, n8n, and Sim differ most in where they run, how they are governed, and whether they are coding agents or orchestration systems. + +As of October 2026: + +- **GitHub Copilot** is a commercial GitHub product with cloud-agent and code-review access on applicable [Copilot plans](https://github.com/features/copilot/plans). +- **CodeRabbit** is a commercial product centered on agentic pull-request review, with current deployment and billing options on the [CodeRabbit pricing page](https://www.coderabbit.ai/pricing). +- **Qodo Merge** is a commercial pull-request review product with agentic review and rules, with current packaging on the [Qodo pricing page](https://www.qodo.ai/pricing/). +- **Claude Code** is Anthropic's coding agent for repository work through terminal, IDE, Slack, and web surfaces, as described on the [Claude Code product page](https://claude.com/product/claude-code). +- **OpenAI Codex** can read, edit, and run code, while [Codex cloud](https://developers.openai.com/codex/cloud) can execute background tasks in parallel cloud environments. +- **Cursor** is a commercial AI code editor with agent workflows, and its current allowances and billing are maintained on the [Cursor pricing page](https://cursor.com/pricing). +- **Devin** is a commercial coding-agent product available through desktop, CLI, and cloud-agent experiences, with current access and billing on the [Devin pricing page](https://devin.ai/pricing). +- **n8n** is a self-hostable workflow automation product under the source-available Sustainable Use License, not an OSI-approved open-source license; its hosted service uses the vendor's current [n8n pricing](https://n8n.io/pricing/). +- **Sim** is the open-source AI workspace for building, deploying, and managing AI agents. Sim's core is Apache 2.0, while `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), and current cloud plans are listed on the [Sim pricing page](https://www.sim.ai/pricing). + +## Which agentic AI tools generate unit tests? + +GitHub Copilot, Claude Code, OpenAI Codex, Cursor, Devin, and other repository-capable coding agents can generate unit tests, but the best tool is the one that can run those tests, inspect failures, and revise the implementation inside the team's actual environment. + +Test generation should be evaluated as an execution loop rather than as text generation. A useful coding agent must be able to: + +1. Identify the behavior the change is supposed to preserve or introduce. +2. Find the project's existing test framework and conventions. +3. Add tests at the correct layer. +4. Run the relevant test command. +5. Interpret failures without weakening valid assertions. +6. Show the final diff and execution evidence to a reviewer. + +A generated test is not automatically a good test. Teams should check whether the test would fail against the original bug, whether it covers meaningful edge cases, and whether it asserts observable behavior instead of reproducing implementation details. + +## Which agentic coding platform should a dev team choose for production? + +A production development team should choose GitHub Copilot for GitHub-native delegated coding, CodeRabbit for dedicated pull-request review, Qodo Merge for test-aware PR feedback, Claude Code for programmable terminal workflows, and Sim when the team needs to coordinate multiple agents, CI systems, scanners, and human approvals. + +The decision should follow the control boundary: + +- Choose CodeRabbit when the primary need is an automated reviewer on pull requests. +- Choose GitHub Copilot when GitHub is the center of development work and the team wants one integrated coding environment. +- Choose Qodo Merge when pull-request quality and test-related analysis are the central requirements. +- Choose Claude Code when engineers want a scriptable agent that can work through terminal tools and repository commands. +- Choose OpenAI Codex when teams want to delegate parallel coding tasks in managed task environments. +- Choose Cursor when developers want agentic behavior embedded in an AI-first editor. +- Choose Devin when the team wants to delegate development tasks to an autonomous environment. +- Choose Sim when a workflow must combine coding agents with external checks, policy routing, notifications, and explicit approval. +- Choose n8n when an existing general-purpose automation stack already provides the integrations and team-defined controls the workflow needs. + +No production choice should be based only on benchmark success or code-generation quality. Repository permissions, secret isolation, auditability, network access, branch protection, model-data terms, and the ability to reproduce an agent's test run are equally important. + +## How should teams evaluate AI coding agents for code review and testing? + +A development team should evaluate AI coding agents with a private benchmark drawn from real defects, review comments, and test gaps in its own repositories. + +Use tasks that represent production work rather than isolated algorithm exercises: + +- A localized bug with a known regression test. +- A multi-file behavior change. +- A missing edge case in an existing test suite. +- A security-sensitive input-validation defect. +- A flaky test that requires diagnosis rather than deletion. +- A refactor that must preserve public behavior. +- A pull request containing a subtle but intentional design decision. + +Score each tool on correctness, review precision, test quality, unnecessary diff size, time to a reviewable result, reproducibility, and the amount of human intervention required. Keep the same repository snapshot, instructions, permissions, and pass criteria for every tool. + +The [reproducible AI coding-agent benchmark](https://www.sim.ai/library/reproducible-ai-coding-agent-benchmark) provides a complementary framework for testing debugging, test generation, and refactoring rather than relying on vendor demonstrations. + +## Where do AI coding agents fail at code review? + +AI coding agents fail most often when repository context is incomplete, requirements are implicit, execution evidence is unavailable, or the model optimizes for making checks pass instead of preserving intended behavior. + +Common failure modes include: + +- Missing business invariants that are not expressed in code or tests. +- Reviewing only the changed lines while overlooking downstream effects. +- Inventing library APIs or configuration options. +- Adding superficial tests that mirror the implementation. +- Weakening, skipping, or deleting a valid failing test. +- Expanding a small request into an unnecessary refactor. +- Exposing secrets through logs, prompts, tools, or generated patches. +- Treating a successful test command as proof that the change is secure. +- Producing confident review comments about behavior the agent did not execute. + +A safe workflow constrains the agent's permissions, records the commands it ran, preserves scanner and CI output, limits diff scope, and requires human review before merge. + +## When do AI coding agents need human approval? + +AI coding agents need human approval before merging code, changing security-sensitive behavior, modifying infrastructure, accessing production data, altering dependencies, or bypassing a failed deterministic check. + +Human reviewers should retain responsibility for intent and risk. An agent can provide evidence, but a reviewer must decide whether the change matches the requirement and whether the remaining risk is acceptable. + +Approval is especially important for: + +- Authentication, authorization, cryptography, and secret handling. +- Database migrations and destructive operations. +- Infrastructure-as-code and deployment configuration. +- Dependency updates that alter the software supply chain. +- Changes to billing, privacy, or compliance behavior. +- Test modifications made in response to a failure. +- Large diffs or changes outside the requested scope. + +Branch protection and required status checks should remain authoritative even when an agent submits the pull request. + +## How can teams automate AI code review workflows with Sim? + +Sim can orchestrate a governed code-review workflow by connecting an incoming repository event to coding-agent analysis, deterministic checks, policy routing, notifications, and human approval. + +A production pattern can follow these steps: + +1. Receive a pull-request or CI event through an available integration or authenticated HTTP endpoint. +2. Collect the diff, issue context, repository policy, and relevant test output. +3. Send the scoped task to the selected coding or review agent through its supported API. +4. Run or request deterministic evidence from CI, linters, type checkers, security scanners, and test systems. +5. Route the actual status from each required CI, linter, type-checker, scanner, and test system through downstream Conditions before allowing the workflow to proceed. +6. Use Sim's Guardrails block separately for content validation, such as checking generated output for valid JSON, a regex match, grounding, or PII. +7. Route the Guardrails result through a downstream Condition, because Guardrails reports passed or failed but does not stop a workflow by itself. +8. Pause high-risk changes with Sim's Human in the Loop block and collect an approval or rejection field. +9. Route that response through another downstream Condition before any merge, deployment, or follow-up action. +10. Notify the responsible team and retain the workflow's execution evidence. + +Sim is not a substitute for a coding agent, source-control permissions, or CI. Sim is the open-source AI workspace that coordinates those systems when a team needs an explicit, inspectable process around agent-generated code. Teams comparing broader options can read [Best AI Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026), while teams focused on approval controls can read [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows). + +## Is Sim an AI coding agent? + +Sim is not a dedicated AI coding agent; Sim is the open-source AI workspace teams can use to orchestrate coding agents, repository events, tests, scanners, notifications, and approval steps. + +A coding agent edits or reviews code. Sim coordinates the surrounding workflow, including context collection, model or agent calls, deterministic validation, policy decisions, and human review. + +This distinction matters when a development team already uses tools such as GitHub Copilot, CodeRabbit, Claude Code, or another repository agent but lacks a consistent process for deciding which changes may proceed automatically. + +## Can n8n automate AI code review workflows? + +n8n can orchestrate repository, AI, and notification steps, making n8n a relevant incumbent for teams that already use general-purpose workflow automation. + +As of October 2026, n8n is source-available under the Sustainable Use License rather than OSI-approved open source. Sim's core is Apache 2.0, while code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Teams comparing the two should consider licensing alongside agent controls, deployment requirements, and the workflow-building experience. + +For a broader comparison, see [Sim vs n8n vs OpenAI AgentKit: AI Agent Builder Comparison (2026)](https://www.sim.ai/library/openai-vs-n8n-vs-sim). + +## What security controls should an AI code review agent have? + +An AI code review agent should have least-privilege repository access, isolated execution, restricted network access, protected secrets, immutable audit evidence, and no direct authority to merge high-risk changes. + +At minimum, teams should require: + +- Read-only access unless write access is necessary for the task. +- Short-lived credentials scoped to one repository or workflow. +- Separate identities for agents and human developers. +- Sandboxed command execution. +- Domain or network restrictions where supported. +- Redaction of secrets from prompts, logs, and review comments. +- Required CI and security checks that the agent cannot override. +- Human approval for sensitive files and large changes. +- A durable record of prompts, tool calls, commands, outputs, and diffs. + +Repository instructions are useful but are not a security boundary. A malicious file, issue, comment, dependency, or retrieved document can contain prompt-injection instructions, so untrusted text should never grant additional permissions. + +## What is the best workflow for AI-generated unit tests? + +The best workflow for AI-generated unit tests requires the agent to reproduce the defect, add a test that fails before the fix, implement or review the fix, and show that the same test passes afterward. + +The strongest evidence is a red-green sequence: + +1. Demonstrate the failure against the original code. +2. Add a focused regression test. +3. Confirm that the test fails for the expected reason. +4. Apply the implementation change. +5. Confirm that the new test and the relevant existing suite pass. +6. Review whether the test asserts behavior rather than implementation details. +7. Require a human to approve the final diff. + +If an agent writes the test only after seeing its own implementation, reviewers should scrutinize the result for confirmation bias and missing negative cases. + +## Related comparisons + +Sim's library separates coding-agent selection from broader platform, workflow, and governance questions: + +- [AI Coding Agents vs. AI Workflow Agents: What's the Difference?](https://www.sim.ai/library/ai-coding-agents-vs-ai-workflow-agents) +- [AI coding-agent benchmark: a reproducible test of debugging, test generation, and refactoring](https://www.sim.ai/library/reproducible-ai-coding-agent-benchmark) +- [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) +- [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows) +- [What Is Human-in-the-Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) +- [6 Best AI Observability Tools for Production Agents in 2026](https://www.sim.ai/library/6-best-ai-observability-tools-for-production-agents-in-2026) ## Can an agent-workflow platform replace an in-IDE coding agent? @@ -114,7 +394,7 @@ A workflow platform can connect a coding agent's output to a broader process. Th ## Are there open-source agentic AI coding tools? -Yes, but the tools in this article use different license models, including proprietary, source-available, and open-source licenses. Many prominent in-IDE coding agents, including Cursor, Windsurf, and Claude Code, are proprietary applications. +Agentic AI coding tools use different license models, including proprietary, source-available, and open-source licenses. Many prominent in-IDE coding agents, including Cursor, Windsurf, and Claude Code, are proprietary applications. Licensing varies more among workflow platforms that can run code. n8n uses its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/), which is source-available and includes commercial restrictions. Sim's core uses the permissive [Apache License 2.0](https://github.com/simstudioai/sim), and our documentation provides [self-hosting guidance](https://docs.sim.ai/self-hosting). Features in `apps/sim/ee`, such as SSO, SCIM, access control, audit logs, and white-labeling, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. Sim's comparison of [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms) covers additional licensing and deployment models. From 1056845815e9024292909b2ef160689880cafe0b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 00:53:08 -0700 Subject: [PATCH 02/68] feat(library): How do you build an AI bot for Discord without code? (#8613) * feat(library): How do you build an AI bot for Discord without code? * Pi Babysit: address PR #8613 feedback --------- Co-authored-by: Sim Pi Agent --- .../index.mdx | 317 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 28529 bytes 2 files changed, 317 insertions(+) create mode 100644 apps/sim/content/library/how-to-build-ai-discord-bot-without-code/index.mdx create mode 100644 apps/sim/public/library/how-to-build-ai-discord-bot-without-code/cover.jpg diff --git a/apps/sim/content/library/how-to-build-ai-discord-bot-without-code/index.mdx b/apps/sim/content/library/how-to-build-ai-discord-bot-without-code/index.mdx new file mode 100644 index 00000000000..eb0f2076870 --- /dev/null +++ b/apps/sim/content/library/how-to-build-ai-discord-bot-without-code/index.mdx @@ -0,0 +1,317 @@ +--- +slug: how-to-build-ai-discord-bot-without-code +title: 'How do you build an AI bot for Discord without code?' +description: 'Build a no-code AI Discord bot in Sim with scheduled message retrieval, model reasoning, channel controls, approved knowledge, and safe response actions.' +date: 2026-10-04 +updated: 2026-10-04 +authors: + - andrew +readingTime: 13 +tags: [AI Agents, Discord, No-Code, Sim] +ogImage: /library/how-to-build-ai-discord-bot-without-code/cover.jpg +draft: false +faq: + - q: "Can you build an AI Discord bot without coding?" + a: "Sim can build an AI Discord bot without custom application code by using a schedule to retrieve messages from selected channels, process them, and send responses through the Discord integration." + - q: "Do you need to host a no-code Discord bot yourself?" + a: "Sim Cloud can execute a deployed workflow without a self-hosted Sim deployment, while teams that choose self-hosted Sim are responsible for operating that deployment." + - q: "Does a Discord bot need Administrator permission?" + a: "Discord does not require Administrator permission for a typical AI bot, and the bot should receive only the channel and action permissions its defined behavior needs." + - q: "What is the safest trigger for an AI Discord bot?" + a: "For Sim's built-in Discord integration, a schedule that checks a dedicated allowlisted channel is the safest no-code starting point because the current integration does not provide a native Discord event trigger." + - q: "Can an AI Discord bot read every message?" + a: "Discord allows message access only when the bot's permissions, event configuration, and applicable gateway intents permit it, so unrestricted message reading should not be assumed." + - q: "Can an AI Discord bot answer from private documents?" + a: "Sim can ground Discord answers in approved private knowledge when the workflow retrieves only authorized material and keeps each user and server within the correct access boundary." + - q: "How do you stop an AI Discord bot from hallucinating?" + a: "Sim reduces unsupported Discord answers by retrieving approved sources, instructing the model to use those sources, testing known questions, and returning a fallback when evidence is insufficient." + - q: "How do you stop a Discord bot from replying to itself?" + a: "Sim should end the workflow before the model call whenever the Discord author is a bot or the author ID matches the bot's own ID." + - q: "Can a Discord AI bot use conversation history?" + a: "Sim can pass bounded Discord conversation history to a model when the workflow preserves channel and thread boundaries and excludes messages the requester is not authorized to use." + - q: "Can an AI Discord bot moderate a server?" + a: "Sim can classify content and recommend or route moderation actions, but destructive actions such as deletion or banning should use narrow permissions and human approval where the risk warrants it." + - q: "Can an AI Discord bot create support tickets?" + a: "Sim can turn a Discord request into a support-ticket workflow when the connected ticketing action is authorized and the workflow validates the required fields before submission." + - q: "Can an AI Discord bot respond only in selected channels?" + a: "Sim can restrict responses to selected Discord channels by configuring the workflow to retrieve and respond only in allowlisted channels." + - q: "Can an AI Discord bot work in multiple servers?" + a: "Sim can process messages from multiple Discord servers when every run preserves server-specific permissions, knowledge, configuration, and conversation context." + - q: "Can an AI Discord bot use a local model?" + a: "Self-hosted Sim can use local models through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise." + - q: "Is Sim open source?" + a: "Sim's core is open source under Apache 2.0, while apps/sim/ee is covered by the separate Sim Enterprise License and requires an active Sim Enterprise subscription for production use." + - q: "Can n8n build an AI Discord bot?" + a: "n8n can connect supported Discord operations to an AI workflow, making n8n an incumbent option for teams whose primary requirement is general-purpose workflow automation." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license." + - q: "Is Sim better than n8n for an AI Discord bot?" + a: "Sim is the stronger fit when the Discord bot is primarily an AI agent with knowledge, model logic, evaluation, and approval steps, while n8n is stronger when Discord is one endpoint in a broader conventional automation workflow." + - q: "What should you monitor after deploying an AI Discord bot?" + a: "Sim should monitor answer accuracy, grounded-answer rate, refusals, escalations, failed actions, duplicate replies, latency, permission errors, abuse attempts, and unresolved questions." + - q: "How often should you review a Discord bot's permissions?" + a: "Discord bot permissions should be reviewed after every feature change and on a recurring schedule so obsolete scopes, roles, channel access, and credentials can be removed." +--- + +## TL;DR + +Sim lets you build an AI Discord bot without writing application code by connecting a Discord app to a visual workflow that periodically retrieves messages from selected channels, applies AI and business logic, and sends a controlled response. Sim's current Discord integration provides actions, including Get Channel Messages and Send Message, but not a native Discord event trigger. The no-code design in this guide therefore uses scheduled polling rather than claiming real-time event delivery. + +The practical architecture is: + +1. A schedule starts the workflow. +2. The Discord integration retrieves recent messages from an allowlisted channel. +3. Sim ignores bot-authored and previously processed messages. +4. An AI model generates or classifies the response. +5. Optional knowledge retrieval grounds the answer in approved sources. +6. Conditions, guardrails, or human review determine what happens next. +7. A Discord action posts the approved response. +8. The workflow records the source message ID to prevent duplicate replies. + +This guide covers Discord app creation, permissions, channel behavior, knowledge, workflow actions, testing, deployment, moderation, and maintenance. + +## What do you need to build an AI Discord bot without code? + +A no-code AI Discord bot needs a Discord application, a bot identity, narrowly scoped permissions, a polling schedule, an AI workflow in Sim, and Discord retrieval and response actions. + +Prepare these items before opening the workflow builder: + +- A Discord account with permission to manage the target server +- Access to the [Discord Developer Portal](https://discord.com/developers/applications) +- A Sim workspace +- A model provider or hosted model available to the workspace +- One private Discord channel for testing +- A short definition of what the bot may answer, what it must refuse, and when it should escalate +- Approved documents or data sources if the bot needs organization-specific knowledge + +A no-code setup removes custom application programming, but it does not remove configuration. Discord still requires an application, installation scopes, permissions, credentials, and channel access to be configured correctly. + +## How do you create a Discord bot in the Discord Developer Portal? + +Discord creates a bot identity through an application in the Discord Developer Portal. + +1. Open the [Discord Developer Portal](https://discord.com/developers/applications). +2. Select **New Application** and give the application a recognizable name. +3. Open the application's **Bot** settings and create or configure its bot user. +4. Set a name and avatar that clearly identify the account as a bot. +5. Generate or reset the bot token when you are ready to connect Discord to Sim. +6. Copy the token directly into the Bot Token field used by the Discord integration; never paste it into a prompt, message, document, or workflow description. + +Discord treats a bot token like a password. If it appears in a screenshot, chat message, log, or document, reset it in the Developer Portal and replace the exposed credential. + +Discord's official [getting-started documentation](https://docs.discord.com/developers/quick-start/getting-started) explains the application and bot setup model. + +## What Discord permissions does an AI bot need? + +A Discord AI bot should receive only the permissions required for its defined behavior and target channels. + +A question-answering bot commonly needs a subset of these permissions: + +- View Channels +- Send Messages +- Read Message History when earlier messages are required as context +- Embed Links if answers will contain rich previews +- Attach Files only if the workflow must return files +- Use External Emojis only if the response design requires them +- Manage Messages only if moderation or deletion is an explicit requirement + +Do not grant Administrator merely to make initial setup easier. Discord calculates effective access from server roles, channel overrides, and member-specific settings, so test permissions in every channel where the bot will operate. Discord documents that permission model in its official [permissions reference](https://docs.discord.com/developers/topics/permissions). + +For installation, select only the OAuth scopes needed by the chosen interaction model. A conventional bot installation uses the `bot` scope, while application commands require the `applications.commands` scope. Discord's [OAuth2 documentation](https://docs.discord.com/developers/topics/oauth2) is the authoritative source for current scopes. + +## Which Discord input pattern should start the AI workflow? + +Sim should process Discord messages only through an input pattern that matches the bot's intended channel behavior. + +The current no-code pattern uses a schedule plus Get Channel Messages. It works best in a dedicated channel where a workflow can retrieve a bounded set of recent messages, compare their IDs with a persistent processed-message record, and handle only new user-authored requests. + +Get Channel Messages returns the 10 most recent messages by default, supports a maximum of 100, and does not expose pagination. If more messages arrive between scheduled runs than the configured limit, older unprocessed requests can fall outside the retrieved set and be missed despite the processed-message record. Choose a polling interval and channel traffic level that keep new arrivals below that limit. When every request must be handled, use a compatible real-time receiver instead of polling. + +Discord also supports slash commands, direct mentions, direct messages, interactions, Gateway events, and webhook events. However, Sim's built-in Discord integration currently exposes Discord actions rather than a native Discord trigger. Do not assume that adding a Discord block creates a real-time listener. A real-time design needs a compatible receiver for the selected [Discord event transport](https://docs.discord.com/developers/events/overview), plus its required verification and response behavior. + +For a first no-code deployment, use a dedicated channel and an explicit prefix such as `!ask`, then poll at an interval appropriate to the use case. This makes user intent clear and prevents the workflow from treating every conversation as a request. + +As of October 2026, Discord classifies message content as a privileged gateway intent in applicable bot configurations, and growing applications can face additional review requirements. Review Discord's current [privileged intent documentation](https://docs.discord.com/developers/gateway/getting-started-with-privileged-intent-review) before designing a separate Gateway-based receiver. + +## How do you control which Discord channels the bot can use? + +Discord channel restrictions should be enforced in both Discord permissions and the Sim workflow. + +Use two layers: + +1. Configure Discord roles and channel overrides so the bot can see and send messages only where necessary. +2. Configure the workflow's Discord steps with an allowlist of approved server and channel identifiers. + +A deny-by-default pattern is safer than attempting to list every prohibited channel. Do not retrieve from or send to an unknown channel. + +Also define the bot's conversation policy: + +- Respond only to prefixed requests in shared channels. +- Ignore messages authored by bots, including the bot's own messages. +- Decide whether a thread is an approved response destination. +- Set a maximum amount of message history sent to the model. +- Avoid carrying context from one server, channel, thread, or user into another. +- Tell users whether direct messages are supported. + +## How do you build the Discord bot workflow in Sim? + +Sim's visual builder can represent the Discord bot as a scheduled retrieval-to-response workflow with explicit decision points. + +Build the smallest useful version first: + +1. Add a Schedule trigger at the polling interval you need. +2. Add a Discord block with Get Channel Messages and connect the bot token. +3. Configure the approved server and channel IDs, and retrieve only a bounded number of recent messages. +4. Filter out bot-authored messages, empty requests, disallowed users, and message IDs that have already been processed. +5. Add the model step and write a system instruction defining the bot's role, boundaries, tone, and escalation policy. +6. Add approved context or retrieval when the bot must answer from private or specialized information. +7. Add safety and routing checks after generation. +8. Add the Discord Send Message action and map the answer to the approved channel or thread. +9. Record the source message ID only after the response path reaches its intended result, with a recovery policy for failures. +10. Add a fallback path for timeouts, blocked requests, missing knowledge, and action failures. +11. Deploy the workflow only after the test matrix passes. + +Use the exact fields shown by the Discord integration in your Sim workspace rather than assuming that all Discord operations expose identical data. + +## How do you give an AI Discord bot knowledge? + +Sim should ground a Discord bot in approved knowledge when answers depend on policies, product documentation, community rules, or internal procedures. + +A practical knowledge pipeline is: + +1. Collect authoritative documents and remove obsolete copies. +2. Split the content into retrievable passages with useful titles and source metadata. +3. Retrieve only the passages relevant to the user's question. +4. Instruct the model to answer from those passages rather than invent missing details. +5. Return a fallback response when retrieval does not provide enough evidence. +6. Record the document version or source identifier used for the answer. + +Do not treat conversation history as a verified knowledge base. Messages can be outdated, sarcastic, malicious, or copied from another context. + +For the underlying pattern, see [What Is Retrieval-Augmented Generation (RAG)?](https://www.sim.ai/library/what-is-retrieval-augmented-generation). For broader implementation guidance, see [How to Build AI Agents With Sim](https://www.sim.ai/library/how-to-create-an-ai-agent). + +## What actions can an AI Discord bot perform? + +Sim's Discord integration includes operations for messages, reactions, threads, channels, roles, members, invites, and webhooks. An AI Discord bot can answer questions, summarize discussions, classify requests, collect structured details, route work, and call connected tools when each action is explicitly authorized. + +Start with read-only or reversible behavior. Examples include: + +- Answering community questions from approved documentation +- Summarizing a long thread on request +- Classifying support questions by topic or urgency +- Collecting required details before creating a ticket +- Drafting a moderation response for human review +- Routing product feedback to an approved system +- Looking up a record the requesting user is allowed to access + +Separate conversational output from consequential actions. A user asking a question should not automatically trigger deletion, account changes, refunds, bans, or external notifications. + +For higher-impact actions, use Sim's Human in the Loop block to pause the run and collect form fields. Approval or rejection is a field, so a downstream Condition must inspect that field before the workflow continues. The practical control model is explained in [What Is Human-in-the-Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents). + +## How do you prevent prompt injection and unsafe Discord bot behavior? + +Sim should treat every Discord message and retrieved community contribution as untrusted input. + +Use these controls together: + +- Keep system instructions separate from user content. +- State that messages cannot override the bot's permissions, role, or safety policy. +- Allowlist tools and action types instead of letting the model choose arbitrary operations. +- Validate server, channel, user, and resource identifiers before each sensitive action. +- Limit retrieved knowledge to approved sources. +- Require human approval for destructive or externally visible actions. +- Rate-limit repeated requests and cap message length, context size, and action count. +- Avoid returning secrets, credentials, hidden prompts, private channel content, or raw internal errors. +- Log enough metadata to investigate failures without unnecessarily storing sensitive message content. + +Sim's Guardrails block reports whether a check passed or failed; it does not stop execution by itself. Connect the result to a downstream Condition that routes failed checks away from the Discord response or tool action. + +Discord's native AutoMod should remain enabled where appropriate because platform-level moderation and workflow-level AI controls address different risks. Discord documents available rule concepts in its [Auto Moderation resource](https://docs.discord.com/developers/resources/auto-moderation). + +## How do you stop a Discord bot from replying to itself? + +A Discord bot should ignore its own messages and other bot-authored messages before any model call or response action occurs. + +Add an early Condition that ends processing when the author is marked as a bot or when the author ID equals the configured bot ID. Also prevent loops created by integrations that repost messages through webhooks. + +Use a second safeguard on the response path: permit only one Discord response per source message unless the workflow deliberately implements a bounded multi-step interaction. Store the source message ID so later polling runs do not create duplicate replies. + +## How do you test an AI Discord bot before deployment? + +Sim should test the Discord bot in a private server or restricted channel with a written matrix of normal, failure, and abuse cases. + +Test at least these scenarios: + +| Test | Expected result | +|---|---| +| Valid prefixed question in an allowed channel | Bot returns an accurate, relevant response | +| Message in a blocked channel | Workflow does not retrieve or reply to it | +| Message from another bot | Workflow ends without replying | +| Bot's own response appears in retrieved messages | Workflow ends without creating a loop | +| Unknown answer | Bot states the limitation or escalates | +| Prompt-injection attempt | Bot preserves its instructions and permissions | +| Oversized message | Bot rejects, truncates, or safely summarizes according to policy | +| Missing Discord permission | Workflow records a controlled failure | +| Model timeout | User receives an approved fallback when possible | +| Previously processed message | Bot produces no duplicate action | +| Sensitive action request | Workflow requires authorization or human approval | +| Threaded conversation | Reply appears in the intended approved destination | + +Evaluate answer quality separately from transport correctness. A workflow can post successfully while still returning an unsupported, unsafe, or irrelevant answer. + +## How do you deploy an AI Discord bot? + +Sim deploys the tested workflow, while Discord installs the bot application into each authorized server. + +Before deployment: + +1. Confirm that the production workflow uses the intended Discord bot token and model configuration. +2. Recheck the server and channel allowlists. +3. Confirm that the bot role does not have unnecessary permissions. +4. Install the Discord application using the approved OAuth scopes and permissions. +5. Deploy the Sim workflow. +6. Run one production smoke test in an allowed channel and confirm a blocked channel is not configured for retrieval or response. +7. Confirm that errors and blocked requests follow the intended fallback path. + +Do not expand from one server to many servers until tenant isolation has been tested. Each run should retain its own server, channel, thread, user, and authorization context throughout processing. + +## How do you moderate and maintain an AI Discord bot? + +Sim should maintain a Discord bot through recurring reviews of permissions, answer quality, knowledge freshness, failures, abuse patterns, and model behavior. + +Use a maintenance schedule: + +- Weekly: review failures, blocked inputs, loops, latency spikes, and frequently unanswered questions. +- Monthly: sample answers for accuracy, update the evaluation set, and remove stale knowledge. +- Quarterly: audit Discord scopes, roles, channel overrides, credentials, connected tools, and human approvers. +- After every material change: rerun the complete test matrix before deploying the new workflow version. + +Rotate a Discord token immediately after suspected exposure. Remove the bot from unused servers, revoke obsolete credentials, and delete permissions that no longer support a current feature. + +Track quality with a stable evaluation set rather than relying only on anecdotal feedback. Useful measures include grounded-answer rate, refusal accuracy, escalation accuracy, duplicate-response rate, unauthorized-action rate, response latency, and unresolved-question frequency. [What Is AI Agent Observability? Traces, Metrics, and Evals Explained](https://www.sim.ai/library/ai-agent-observability) covers the broader monitoring model. + +## Is Sim or n8n better for building an AI Discord bot? + +Sim is the more direct choice when the Discord bot is primarily an AI agent with model reasoning, knowledge retrieval, conditional controls, and human approval, while n8n is an incumbent option for teams centered on general-purpose workflow automation. + +As of October 2026, Sim's core is licensed under Apache 2.0, while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), whose production use requires an active Sim Enterprise subscription. Local models through Ollama, vLLM, LM Studio, or LiteLLM are available on self-hosted Sim deployments; they do not require Enterprise. + +As of October 2026, n8n uses its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), which is source-available rather than OSI-approved open source. n8n also documents a built-in [Discord node](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.discord) for supported Discord operations. + +Choose based on the workflow's center of gravity: + +- Choose Sim when the main problem is building, evaluating, deploying, and managing an AI agent. +- Evaluate n8n when the main problem is conventional workflow automation across many systems with Discord as one endpoint. +- Compare the exact Discord inputs, actions, authentication method, AI controls, deployment model, and approval requirements needed by your use case. + +For a broader no-code comparison, see [Best No-Code and Low-Code AI Agent Builders in 2026](https://www.sim.ai/library/best-no-code-ai-agent-builders-2026). + +## What are the key facts at a glance? + +Sim, Discord, and n8n play different roles in an AI Discord bot architecture. + +- Sim is the open-source AI workspace where teams build, deploy, and manage AI agents; Sim's core is Apache 2.0, `apps/sim/ee` uses the separate Sim Enterprise License, and Sim can be self-hosted. +- Discord is the communication platform that supplies the bot identity, server installation, permissions, channels, and response destination. +- n8n is a general-purpose workflow automation incumbent whose code is source-available under the Sustainable Use License. + +No single platform permission makes a bot safe. A reliable deployment combines Discord-level access control with workflow-level allowlists, action authorization, model safeguards, testing, and ongoing review. diff --git a/apps/sim/public/library/how-to-build-ai-discord-bot-without-code/cover.jpg b/apps/sim/public/library/how-to-build-ai-discord-bot-without-code/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f4a71e0aa8a6d304d7a1f2e81034019ce33afd14 GIT binary patch literal 28529 zcmeFYWmH_<(k|L)aEIUy2_B?zcL>(FyEkq@6N0r0L|8^70Uv|HlvM24Ew>7Q)xU z!B7KWv0>n_VgB?2NC7YaSU4CM7{EUd#8>bLNN}((-{Sr2^}pTlXBmJ72knCehXuXR z)cMbD|M3Zeh#5)Vf80yTSZcfkVZ5?(5&-{h-L`@OWMX->wk2i-L@#g@zF+awBOzu( zJFr7}rZ>elh?(?UFRE zB;3M}lvnZdi&`t*GtW-8Q;ofO|DU}^vQO}fbem5J6G;veBLIddQM31qF)@jlLCVW1 znXW-ij}CuYaBcccm5s%Gz(^Hy-Ix%|fBF*az~LNyYV8-nq<>wBRW1cdOJq=9Ip$5B zA;x+l3I6zxLCJxd^RC~3x8)kjkVRl$K;>mMxs3} zVXqo@Jm`7*&ucFy68S6MlLuLAmZL8%Bs9lq3^z-jYGnPf!}lt+{&3r8gJng@f1CO=EqKf|=K$0OcCynJjN_%Y}aUsh$!;g>rYDd{8s+KB!m;2*^2q;GK1P`x- z$WV$Le!ys_aFfC9xfO%(CTP}(#{CE_uvOKn3f%{!EM3)$z7q0v!n;KA>*y^3f^a`@ zt@d_(R(bt8RQQ>U>Gwp+6az41Y>ZpS!oO&HUmAci-B^Dlk#}ip?K~;KYP*~bJLM>j zsglhcHS~FGK(;(G>+T-%Lq`6PTX#CRiSesSZM06q!t?`8feT-Use~1lSC=E;)m};# z>qLhq8FK-vtFdNq%3gXo*z;W7(&O zeV<9vK3fK=TMyi?zYW_|ilF`j$YXJ_7{5hOGP@QLS_ygfZ~7T9SL^O0ie$mZ5}Ckv zol9HEl#^T>mMZO<*E zkR~5;IHY#)XlG`V%YsJ_IWJHO6sO-8)^RUR(J?OCzC^m8@`r7#Vs4vUUh=whfzftz zcM#);%VfPWP+6Hn_LM8+Fd18;8S91Ym$15&d{#D=kp_GGLosDvKjp@L<x zBYolx97pxUjIEpa&XXEnv><nCXG6I;vU% zD@6gYQB3zsP*vqf(nxQuHExh?KaU$Aj|UYTm9fI*e7LXeHi;##4>KHbG)h3%?C{q4 z0ULNfK9x;)_Z#1!TsbZ$YF{{t(bFK6J22*GQ%YD&&P zrC6gQ*nj4x;#;U5-5WCZkktQGoT`goDt5ImmMKVjjGKMTc+<@y`cI325kKI|72F%u0tRNLM-nPHh^U3kREn_>0kloG4DQ=ULj0Q2c9!6%B=cz4XPi3s7H0ImOSI;t|z~z})ushFa z4~S{LYac4f65B`k1~0F-J>igWDe2Z@c7Bx~9Ggri7)d0cy+# zH>Ujq(_Pc7eYUXoBUu17Y#nv=L)`PHk01R3!peCt8Vk+%-fotftSW%Jw_}()gA4jg zop9&SZvilsOGg;K5BFJE6ER7Td==A|5ai^@F&-Fh$cX|?b{po1t)IVdt|$O*{{R4U z>G$$x=xGUceOdW55JA;3LtN4j|%0L zO7G015(fFq(9dYSO;2u$#t+M8(LHt@b|sb|=T@sBVrP7n4Ks;fuIF>-hz4GUGbRTr zl^S|6ZzPI=CrPLuV;u{FrFg4^!zub{lbZeK?S6#{D^Xqrtiz#lxu$rW61z3I^3@<& z>`oXU+8tHx(pnjh@AO%j^c9v$)&lofj7pX>d^6c3t(LwmSf(fi@(1z|qi2U`;yUVH zADr2K{6X2&6sVEX`1*Z2Ps*BWmhpmi0=3ASAuoxV#4WbGOADh znu$W4l@&HoqU|640CEcoT8!yHli#jBWI#O~GD1B*>Dc$$+qL|DGO6i@@W)`d%2Y|E z_rw#r`q1RW){BUJG*sKE3R|@4VSfhf$Zh#k7CNe6h=mOsJ&#hX24rG z%Jh(QfSc=56bDxfLiQ|T&XB!57!KHAY`f{H!H623I+;)rZ1;_Ec)^u+m|WX&zpv$T zd>(@Ew#kVy@Ad2(=aT%O842w*0GKIcGLaZ2_@#|lB=A#F^n3|(fQTm!f5&9S@P6L? zRo+|f0yxpUcqyAQ! z{vSXwTT|-?b3;{>j>#Fk*J1~|w8WK%EM61o^Oi(fgLXP5ch`88%Ee{~Oc&O>$-$eM zDIe9F7x!{~>V-9)o0GoG3s#v}1HbF;tb-WhM5%f4_}>LVaM2$3ZIAjV7!spnpj$(sE!PPEKofGO)zKZk zU9B(4x=L^Zi+%rZq=zveV2o`YO@LKSik|hxpE4~cDT~^hOE4X-@c?>3I?4?0eJYr^ z_W78>z6Qf7%x=U{O^WB}_Qv|GzJqK{uBH@{q*-r&doGc`YKu)veE4pom$4qU?}hEn zG=c^Ij|JpMXT#@}SR~#3Mw1_GCVjd#`q?%jYk-N)lVA6DCqZ`RB~KGeaug4reiB zdwe?d0ly=4cpfOMxwzM=blS?Vr4fojY4{~I9&L85FhjMh{F6Pfj4asdmyWACj!1)mjQI>*3rOC*Z>9<3Wn{s9 zhKm}98XM{?*N!mbF0!jhtzB9R+&~vMG7HQ39o!*4SnfzZMQNXuRfc67Mx!cM~qNts{UhId@e2RgK#_b7$Pv*JQAxS|hOIXk} zr8K`lW*hG{g1eSZa{4E+(s%VutO+TG1rdgY&WTso($>#G!MV-!h5y}105FjK)0Dhu zV2!#Pa`t#Ooh)`FLEE<>LcS6k)Qs2oJ|>b8y4M9yO7WoE&*LuIv;Q0oskcNOU-d+c zq)DIXEqa{N^sN%nsP;|)-+NE%T4P4K6VSp`qD|2v@btH zzc;p575-@|W7X7Acy>(5Cfi4AU;f$k4}>87n1*T8#ImBo-A(Af{8 zMX$`j3_E>os_E}pPv`GH8EeLy1n}XELqR_;?AI>wI>fGs3!uqoMJh*6^nWry zNWm|z?O<0XXz38COIQ)ya=3Lyk~!TyZI1rhdItyIksayfkel~BLVy09uv4ejTPyYl zKzr%#8q=JTCTH0#aP0di7JgujQ;qI`HHiiP(A-y1EpH>KK8LBq7$N+A!|qqab8giN z86*Ny>%pVb=SH<8F3D@5uSuYs;%BfRVWX>ngUK06jhy*(MK@$x8g3l;EVpw+++R={ z{w6n!td_-u>_*tfHs1l>sh1)|KV+X7lfh@yCP)zz4XW< z^q{W=TX>?H>}mV=^ZSH8Wt^^8kBLL`z-TFD9A)9^a^tT7Z{L*nc9*iFhGq;fRaUTq zkgA1I-)%8_QO8&^(TzeREBRlc`pe&H5#j8#-%M!N96qe&Kd{^PDmNU3e)4>N`d2+f zn}O7N6DRI<`e>(HQv#>Vcc+2pg?GUeiBAm^!gts4cWd%9^D^-1Y<>;%Ob;JhtUpHS zIpZEbY;LTJbnM)Y$THi!{vQI0?2$x(vVO&nbukd{YSffgR!H4s2%uF6wvMEHBdn0{ zm;ESOI-?r}*3D?m!?;@U$*=_+JWVvO?4W!))56ycz4hcewAr*9PD|-X1aGK;h;=I^##w+$qo{GVr2yG4}wMy{8DpFMd;tE=o?~HJ{PiO1sMtKzd0gYms@+mGuo(+~)B=jnDX#Ww0ajjCF?$6X zlBSG&*lJgv;WvKeK0->RTvXm+rw}@IY3jmwd=ZAJ{$Cd#3Nhlbv9UP}`>bAs#*nlB znWte_tG$p^7C3foxdJ$YzhW$)hKx9?p+MyDi_K3jdq%~6gmu98IBM`0nm*k??fv?- z_8V5m#iEtaHL{St6keJ^F&@6~8w_7@#)@Q(Wt72(LmV;o(RwBg>tZl1>Y@P|jvCQ1 z3*e1!+=R~u6Gk&jAEvf@EwIbeuk_v{Ow@Hlw2Gm4OBb(oI`xDR&NbS$>SkexdYnSY zVX8|ozcp)=Vm)aNin%tm@N)Oq&&%tGva)he%F=vmR7OXq9=jQ>{UziNAWRKs{s;?RpF@`A}$^V=;a8SE1%r zf#3c2r9WXCp#x)s`c_@K4TC-a|I8<6U+AH#t*$(tlm-7_!Hj9C@(-XPx|!m^<*Qdm z<1!Q*e4PI_>ls5n_&KY>2glsa%>-F8SW?YWRt~q`ZBpv$>Lnwi=y+!C{vQIo9N7?y zh-g#BFNc>-RXxfFkAE8W#?%k`sBAI&L4D>oQ7YE*4?vA;)-B(}9D$O(WTn}U9BPvY z&re=203btCkibU*wdD?vJ{KKmZ32y5xQuvvE;MAh!`ySga!KtKuNj=dVL0A8q}kn#6n}xfF2+X ze0-+&5*0m6T7AEXeUN9g0fuRogeO<;tzh)0^5;dOa6KxR(gUa1bF}o0EAW(a>Odfk z*J5s_wx?ss_`L)TV+^=*T(jzQhDwz&XpA8QH0BLmFeyCz9iDW=HdL7et$vD;#Lw?L zg^6s9%VTrTSDZ=>8aSVJXX~23@#I(+aPTg{gL+3(c5iNQm}JOJOCyk&oJ6UjV`^zo z^3x+0&)yCf(a|Ki&=Yu;2ILG2=w6IiELb(KK#^;M>q%5I;0S(q>&?X$WA>ZMDR`R9?)5xb9en( zn*K|GYGv|fNnIzAkrJL<^G7(EEY5TYY|^sr;abNhqpdDxok};TQ|dJjANwyKZlkZ1XA!KS z7-~rEmun}Bh#=3@GI6wYh-^G&ion1{?bYyqH#u{g^7v9gC-Vq~Lu0fHCmyKGwxeXr zP=pmMR@cbfK4mHg+pJ0k;Kr$jZ~sipQJ30^!=|y!@zM+Wuz2%gBYhH4{O^3*zLOPA zFZG`|{TdF|;kj*vGTEeoaaQYh)5I6pCLWp_lCavWCNJ>j&I+NXvYbxkN%YSDX>3}d)#>B-^>;M1@6z)osoZR!B=xnclQl%T>~w`bI8|aH}GPk?_~U99iY=^YdE3SlxtrLlQM#Yig4eC6pYm+ zCku7tdTIOYq@}9u;5O7kTzxSzU1RtYB|W7I_>hxcoRys#bYK)(!Q=wF7bnUefSD`j zijvFxQix{#5xnrvPxJyt7fjBS1{Q$~TyF~9d$yKqb8t?)^~6hS)0a2Thl{k6S`2bl z9LR-m^t5kBO6-LF326pou9?lgW@a^nlJoemJrby?BFEeIAwm?fh_kZ^ z9B(&h*3Sf1PoP1b(3j7v0gF&N`$s@PJoHM{x^i35>8`(U0(nInZ}Ky9Di}(_m*xY) z9Z5q4^DL1AjwGYZy0&@oSbjQ;{JppIf6W*aNKzYSPpnq#xHoTgOn$Hamv98VUN=>J zS)muJ{4+g{arvm-hL%-s!MWRrcZVx0|Pv_@ILx%h&fx(C-W&EK!VyzM0vO|wjC7)Xiw+B2JY{RwF$T`}T+2{x>jHJ*&6;z{+ALlkR+771)b2;t#-G zeLAxl&gd`-bT+d%qLOL1mas?o&cUq%6J0*Ey;836~^lxW$e%C;4ppW_P*R?L>X<)yUVB_`=BC#e`$WN+!qgz`@#%~ zz~Jf0(Y+(2FX*4!Iz9}M+Fy120k9$Q()lAm*;ZdNzxV6s9T{4emN5!w&QDfgyA9DbnSVpDIaK>302Y3<1ih?-8s5Wfk7T`e?c>x zllMzMJsM7$Nt}LH);=9ybM1`L&hU^Hnq}Nv;t>vr*Ct-h5d_wtwAUDC%Hg@PPYo+k zh)X0dTcS(PU8vYFR%qZE${pAYrO1`wWK!!27NljXMNEh8atg%Lb2h zARXV+{51pap!$_E>uaMkRwB;UH{`Hp+NHT@dFdK@i z(4`kV%Yb>Bb=pMg$1Hh9V?nYD!<+aBqsS*i1r+UQXFc9AE$CtRxS0CTN z53Et(Nb5X2AVWoHk9Mj>OQ?v(D#MRNNlf1dyX~f=p+Nv0TY^cWg`#`l3e_hTkWCes zc%Ivnik^rna~Hz$$`Ieju#1}+Ya(`rQJ*?Q>cV=_9_dtui7Mo6!rN!vf=PG$Jo!V( z9UmeZ(t-FemAGAx!tMjYGkh#Sr&XMeX@mzqpHZZW(7FX0mFQ~BEC}4WV+noU{}ylqqrBL@>Gcsvw}0plz`Q=oWqEjN zC!7G=k0zc%CM;b223zD=mTz!Yi(iRKh}K)?E|?p_tYdG;7q#vsL};066sBey$(pd& zRUnXX%bJkU)eX!NO+PdH z@CAxJPngT68xrBFc+eZf`HmsTkK4gZttkaAMd&S}aV79XOpges26Vu{ATaM|&h`F3 z0E*N_A7Y*5wOUlnSE>3>V+SKEP|LpDd%97S_?7?oO!+JMH^Nl-SfZ|+m90c$Kfeqx zDi(Lp!c?d!ZcxbH7w6dz8s;ulxh`~dL@m3LPdw|!=ur8XN0N?eey9~t{1P~?^L&Wa$2R!WEy?-o)omKHKgeUGocLaUW=GR8Fp_z@ z1jL*VwNRJTP*-=MF6Bw@aAafqdvLNFKRmeO0!9cC5Oj7W&1-?9qR1aRQCn z?p;6rrq5TGGu7xAv-DX9s_$UEf4mxCu5G2jm$os+UG9-&b`1@Szxh6DT+~`1cBW6 zB*T8iIY#T-wvPLGviW7_xzq3lRBt6W*kwmNZY_3 zF=m(?D5qdTdj(S8aE0KTm?W=}-+F>b?NT+uo=%~O<|3345;_T)1GD5th(#kPZLDK; zkBq}s_<1LHtHe5h&XCX!dB_m$+zt3ZjzU-Mo1n5+^4$cZw>lA^V<=kO##Epe#zL62M~Z$WzeA~c zbKl+@Q5G#5sco=5PV@siZd-&b&C5M*@)5;kYnc>oK7_$N)=UI8E&PLz@3D{DGxu6VR2XU#3SMF>LhdpSK^2Q2z6Wpf=a4= z#}ee|<1giurR`_OjW3+;@JwkJv_Zi0Z*ji@R)wC6_2=i=H2`7>1p52947BPq=`Jz_ zT%;m3U~Htu&YcjR5&gZ^7*~UkbwONWwB;>K8KZ`*eF#%fzazS-905zr@|9X%~WQ#Zl*!y$@){w(6KO$i+Xf5`pqj88k*>M>A|JSj_-^5 z<9l6Pfn#`6LF!hB?4X17DCqjkZwgk&4fExMW`4+2h{IC08KCKxDLA|c8AZo!uJ1}K(I zsb-W8gc<49yrdbG&u`iq`P8)c46#0oVt231lWd+OV}n4Vm56oqjfz&%{5~GhW!t{> z9NZcUJ(_5+Qgxn!MJsW4KMyKwiGHwQDH!p>(AQ<`Y0jebuJBhGH)Ev|Gd6)#%bNHb zaZb zP24tM9>q+(^%GY9!WJI?U_JYp30Zk1O3yr!zfqw#$IVL>x%InWqBxLLWz66%s26lZ z(aa3_i87~_tZx(X#Rs&6L@uz;EWPeo02M_|U+jqK1{z$aI2LlhfQADV;{(v9`Q5BN zx+(pV9%%6?f%S{`F6elrvgdj?W>v(N&TD+H?f7QJnCwT6{$O*m4?hrFzibu&;mX=+BYyUU}ti3tQS;JdR^SD3nugq0!MW2M4;RIMlDrU+ zVe6GOe+VrW;yO?Q2^!znhw>T3!c6|2Rlh?4e6&-1b)@bAoYX{6t$fV!tces6eB5wXA$JUH04^?JVI zC2=|~i;o^IM`#oGKSS?6IW< z{7Sw~5uIz45i!d}YF%T~gtadL?|qZIOX|W%h0wudz2q_9?Z9=Kjvr8BovnzsgSiCS z4s$y!C9FEANM7_Z&>mlR=9gYmCs@B0v+yXtD*JlrRgF1$dz|-;*b!f)aY>9m|G=^E z8&x6{M`^=FXkMMlb5?I3Vet+Vj;WG}hR#-4KyUdWEa&i8IcIQaBqyi+CWSg|zKJ+& ze&5|8tgNzdo+lCATu>sh;xSG>jMm)IXUAv#k-Vx;yL@5+3*@)v+Fa5Mn1t6}5oG-o z|MlBcuyNyVrVs>To?RXdvH)!asGqqc<`RxWVOF15Bh%3I2OccGc z8jI_H)-)QgD9f9q@9EZ!j|Bl-7kX&dyo-DOexE%gc>}g%F;DFFcsx`P_44ycD_c`j230FR?Uy{xx!Ahui|oJP|Y#E+W9| z8x*VsAv(L}Ak(``HH<^6sr(qv1{A3^n4|`Xh^$T62DNQY+)Da1FH^-p1rC+WyuB8~ z+yyq`%Va0~-P3(*2)wG@{V~94yWoNhYkW-x%M6Pv`F>$&G}y=f0lKVuVo6cT?6LU= zV9%2QWhqxJ)g|3_EL}8xU30nI7OajY!`b$nQXOgftDLK&-3V=3Hjgpf7+41Eo2QhDB(z$BT)b_k` z#4u?BF=D~nG~Dht?U#J|7m}1qOMu07;bF1lY4BfoxnNqkKHPySx`*SVo5+Z)jXW)M zFlP&__Q{yff&os~T#t#1%rnl|X`<+bdyV_Kn=Pc?v6*geo%pdfZ zGNhGmE<{193H!oyn+MPQxQHcWgNOM6*xPn04jfHV0-%NaVkIU6C3#%LQ(L}j z+sAk*$TyGXywEuM8!n(`L^Zu31$@QxsGu+cB&W$8-@xepwOLodpqwNPR3eSpP-V2Z z@L@^pB*E>WODTd5*@A^3JY5C)V0?V({)1k1?Rf06u(cEGicW-lf)xl0kk%qQfXszcTK-_r-NP~>lc>tG$)Q6F}pddF)m2_W3Y4c$C`vD1TMfl)h z54q|2#X$3b7Go@#)=7V=2lOZy_60E<|R^Xr#$gl|T~I zeA+A&h!#_x6{eKTr|JeWNV&S_4eyviKBF8tH>|1yU%)%tXif(ELpGO38wrM z+4)jtlpnn~6gzAjFlFsEHP9RKB1{S})I=y}dTJu)nZG^0om0`+!ZsB7;XoO+PmOP6 zjZg%p{50bf^P7BW$RdoAm1J9scwjfhR`-x@alN)33icu^Rx-q-O=Ud&)j<7y;cu_M z-;zrH7M>+bsJLXSp(?_ia~?=Fl^A4_r;^>^I8Yrz@xbC1@))UA)p$<5G!)5WAI|8; zig*79raCxk8@@y(CtrepL2fEfn*IP7WCG65s{=AIRuPH zH2u;I0U`%{?tOD|la$|i&2`aI(kOddbfWL)%n^B+M_mY7AqU0gQ!-4z zRC7)If$q21yXnj|=62yVcA#eEEDj;l4>va+G5(|dvk&=!o|>;cJSw9@4(xKyd8x_K z^r*hnHyiIBK!3BNR5_%!|67fQVPIh0ka*to?I1zdIeiEeiO{SF4RFuTh zRftR_|LOQ_BB6tYtA)l}bd>((?m1KEwn(P$A#b;0F~2o7@|H#K)HR|&5VF3K{u~VD z%a{A$%n0cq4LN<~m9Cu4l(T40cq7~2p20N*9`HiY*|!|s=V;R#Nl(@|-&hkZtAtF@ zgER5dc0~2rClD7}Dcz!^zpOT&4+wOzvc#|ZY3_M!wwFm8*^zyjor#2C3~AJrqUsBT zF-{2Zf8a~LTw7Iu)C5v}BOBaPvoicpk18i<`T05O4*%PyJ!T`~I>fa%BCd~6ilG;d zXmK~BnB9<~R5X3~ zN)=(t$Vi>vFJVZ#t88PbSeJk&YJzVp@rnS0JZ_JA!X{V;26rz|+v0aNPjY381?pkLb2hy0k11jfA6o6+1n3OsEYD`Tk}GVZ zi6z#<^mk&!hix&}X1*(Ms|6!}^OdC2Kl7n6TYn5V1s57ViNW`dkiHA|Ct&NAJrjJ z_95ofTQvjXL-ltN!X6lJABE|TrKo!lY6;#9GZqv;Skc|~nINTW8rb++R3q>x$TFzf zLg4TQHG?O>RGM*6)IH%rk*{N?!gVaO6jw?Jk!0(*>*{`+>;`Up&E`c8P7cmW>M;|> z8#@3SWl6?Kt7+y1T=ObPecKa1B?}|?qItKI& zSNemn$Zb78(-73vzS>Qf`|G?5gQkyTK48?_xy|J2mvw>{!-2+xFU7Z&zaska6{C^d ztV&YK%u3<>ET*Nr zKgGx&AN!oC;4}Ju>S$@9R4=(JxO;rGb{AV>Y|0`nnB_(UV*zX*zLb8b!f3a4PY*|0 zU`-(2T1D>0TdPmsCkVUG%jvvS>-JMK8(1COP-^>TTStxe>44AEaQwVjo>M5e7M|6? zsHNCVmJ9Q3+AB*}{VwF13})jA3s1NOLAI$1fAcv)Bg}Gmix(qZ`3DoOzQ7<-q#wbf z_`{qJbSFTzRjihN_ga25v^bV%_spf+S-gVb{hb_j;AKJZUrdhsulTf~dEs)u=A}nr zbe#;T{mvH2Zv`K-QQ3o^%bAu6;EQby_nuqG0_m8gvephgeTbO_t>@Zztn_UJvlmd* z-NK!-s*KG7YG7#?{1q&8|71d`Rvna-E};G-KGA&@fn7B(=Xk-(pqr-s@#iVSYar&D zsvI?Ncut0gvFW||XVjL!47sZ%BIllRLadb4m@W3rESJ53GWQ8k5o3dvvbpUw*S-t|tSIbf{T zoU0Cz>Z@SoV3YAVSJbo~`G+{Osv1s=O)w}=qyn@&?r^+f8uDdrC|A|Tni_juuW zQ5n3@+(A6O13!+wOn^@FOEAja+*s2`p;$sB^U~oj&m9^Azb>mqYLcCw*rTJP&D`zv zQ<`?*&w3W!WmJ|`k~IGSB)+0<;me?R04PeZgUeOk<%f}7_TE_}yg3Tp^eW|Da18j+ za`G4VXnI-|z|j1i)r9U|*r)jT7ysB%mW7_2W$o8HRf4(X0wjNJBd&Y&w$;TU0dX9) zNK)<4;6O1n>&X^%uW|Z1a&#v|}L*fm6n_(sU}hpBeATk*&+hB4T}Lz&Vfg zzf$%%*cS(6Z|ahM_;S>;19P=11H*iPq2?-r)<>vQrKm=5`;3=!w*;JcCn_HDRTl2m z^`+T$N9co_MB&<&S$j>8eG5K`K~_RAqpO~gCQRuHO}if+jr>M0LsN`-O?z5e_7lz{ zn~9SzGUVM;uq<44{rOYzp?pRkEOd#e;Os?L>Hb$XS$DtiW<<}pcrW_LytJ3x{zlp4 z|MUR9Dj)p?^Dnb=xWb-5gr~_-*)7)2?K!*>_G!LK%Qw+gdU)NxTy;lmwqQO0*DnU=vsQ`OzB4_si*-z@GUXlF^kI&fq7gT~ z!W3F#;3{q-a!2ngJC>HV9ZU&UJu zuk5C-8xrkP9K0%+*yr1%Ew4q)9y_~;mH3{yLOC2$3H&}5yy`--5Ow$oGjK96F93zp zZLjIXmBK7=o?u^=^X`*rM2Fa&AAhURn5KXZ=(&uiW#FhiYz=9lQxBRjen-_x>hY~$oa~o$G zrcD}3c2(<_F-(Jb5k@^fe#u0O$uM8`{Q;DJg6>yT!~(#=z{0|zBEusgKsPKx_aMT; z0kGk*aH!Z3a4D(9Kx!t=E_EEL>Yw8i^6RI>%@VuuXrP-HO#`~#aZ0#0&=yQP1r}~` z`N#cZ6C<(+bP@fQQ8EI-6x$7sfb>|AQf8Wgl7uTY^AA8w9?`22BVj`$yfpOuaPyHr zfuaYWe=vb*@qjTCa_I8ub4;|py_koi>Fg!UxS4IA%v%ySrMhMAs5%fcM(@FplUkWJ z$xLb;-RjS2v&Px-6TB{U)C5KUs@Wc+r%qe*c#M8&G@(LfH*B7aaX23)w|A!lrlGHI zK2f}*yIKVzn8V$wtl-%Vs9Z z+8+ZM-2GI(k|aBF18Pcb`lKhO!b2?`e< zeFaTci_yu+;105l>y{E=mk|mDAwO)XqoZ7yV&-_Bg{UrKfEcm^S#zaaS0Z3eVi;&U zyWri&;f11SHK>-}Nq;SjE?eCyAWXX_=B$}7Dd{tbO z<*8{MM9_c5+o}2T_y;hsBh22PKSX$y(YTjmbz*5I3T-zuN@FhR7stcOm7wTltTu&b zFVe8&%rErF-S$qks>3@6AHDbSW3oXdXP_?BQftzNMW>X^MkVa;W0L-v7|^aO-ezWK z<~zW~?#uD}4`9g1`Th?eHq6&7S|WjcA80csII%q1?+sP>3zMpbbH`>!We71O>waqxJZ*XAs(;}+aUPqHeaiycV%SxXeV zEQVmRXxgN`=T~tM_2#lzUJ*$M-0dd?aEUTGrXslk$KA(;gN?M^$$A)I_O)JQREhR=Gw8KyN*fD{s8C=LthOty+$)S<*$i$D8WmxLwsl{ zKoTr`WJ2k{_MVanu9O9j8@^j zM#|a3F6w!VapkwIKKU^kl61iB$;*zRQ6 zIzegLrBBc*jhx}+6{uLdPIGQ=Xlc|%k4E?|dtpJki{60saCQT2Y*yCHaPY3tu0)K) zL#}UhRgM$?G+I1(8er)#w{#)+8b-Rb7v21pQTl^`PB`tuxORx5_lzx%by<`{oVDz? z46*RJRPh`l7*^(Jxk3NSCe!TuR8x#TqQS*5KG4|W2i>A+>6`LBnp}T2TAGhavn%I= z_p}N*=;_;A=5?p^4GBvZEG0gHs~YWJABagOWWdlI(xKY;j#tCjvXB93dvQI`X??l#<1g$^PfRm#ik)?fuy za#@un(WCvr#hJ!C57qpb7Ui;$$&GzOOmeL+5tQzA8@%E~lOQJG^AW>C`Sk&*Vxst%3nTn*>YdZ2`}Yz-s&E$Wi_T>f=cK zS3*&TOJ~JT#i;L2;8Y4-tQY5(Of-dxz=f7r$%a=K(h~ah@P;3l8 zO8QP*2GVV;T=$8DlbJfgT7!Spon}4Yo#&y#6cS&3wJT+#sk1a2RwP4GdmNJeLMD+l zi=*JXA0Ij>B_Q#3F{&?a)`C`=ao8h(;Sb<&kBW{oF#Sm%+g$6LU;jKqWrjD-2MW;W z+Pk0BWSdnLcJ&Cyq$U39k|>2~Z);0M3VY?n1@#s{sR`035I#kLF*Zgfd@&U{R2L4} zfqnWf7s6kPVY@m)vWntsEldWbv|sD--&X^%4MRl+U4F+-MXr3c^)#?EZJ0n@k3ipV7nnygxj1Q}AvV$}5f@=p>py`C*y>k@X*O+}aHd5lL1(wFL* z!X6*pQ3{GTYLAzxPE1SFLli$v?I3tVvuAHJQD|5~B3Cuk{}FC}XIlj3$i+F=|5q#T zM5lMBspp>7D5{r>QlcvocE^OwZ-ubixV@)3+D3S6Biy{dn#p46bWpaSgN* z${ouq{+_kc@v&)l3&}JPHZY0m618X{yG1W$YF$hr6czB?5p1+QE~Mq*+K|l!j~}j7 z(DDY}*dFtd)CDn-yJV?tb{g1zNEg6p8mivpqCKOBT$)B_Hbw_zoF41M^6`(cmTCDY z{hn$dY<%4;QJApW4-dL&ZXR>Epi1E!RWp>)BG5Po{##VAyNbS_@UiLv!UQ_9&0{nT z9)&d;9?lu2<O#WCbN1?oI2*K9Q;Fjj*MEVo>#5xtwqVrkqu){A?!I9Hw2^cBzYTqq z5dSehxNF?Lt{hCxe9`!^uAdywT@16XXh9+}G(30SvmiJHtnhuIWm`u5Jl6|2wbeyD za3pB9Eq>JT>f=x6Sa|KT6T?J`Sh##g(l)&FY6V8mte8B6hU-2k`UxAQopTQnq0aW` zX24^~QeI-qsD0+}h*nhW@0Ig7rGIZj^kGO~qPorcl7F1#?6lek%?`7HP2y8Fcyj+9 z0~-HaT~Xgly)fuYJe1NE9E9m+HAHLfv*c~|82P_bZYZvrl+#ih zc8BDcjDug(W=mekm_|!5qg}!cm&x8tFHJ?%?m@%RzoM?x{H{y1c4DKX-N~BRlXmv% zbU{xcE-xrNiXY#7K;c_nt{2(suWm0-ukRz_`-Q?oo1loU(m61{Tu)pYF}5D_t*ls! zV%kxUHaX(fgRMKHb+`6(Kw`);Jl03M5W<(7`E^9kA~|=Rr}$Dd98+h06&s?6{Cd9W zIcf0uu>Jq)>pjDoYPPW9(2ObPPxr zk={W-43UmhX-XBP_ywQyobSBvkM~EeWM9{0_N+B)*4*n}YtIIKzVZv+JkdN9^Xt=B zMh#LCRKL%D>54vDwvqfXwa77RGh@Mh5+4xT?1z7?ApEbfCDY_Dzf|8%#&#k#zm&h_Aun`1epZJ3>!y-< z31wA!N8hAko>^BEZb|C62UFk;TMk@CrFJ48CX6VvC#yH&2_jqitJg^nI|3<{3^NZd`C zan+-lTYfq);ECC*OBjukh9}iz25ftvDeL;%Z@C7$_U4%{zGyg3j3RNj|7tMj7B%I? zuiaXhM(Cp}WASYng0DQ)fi!2ING8;CB0gm#R7V zXh*p!9~z}x3ju+YIcSmHh~Y`0Z0-&_BXy{e63b-TAzzvIzk zPS!ch*@Gz$4!wdRLMjqz9KN zGa9u-YG)qgbG-R2TU~quHSRKhNA=yzMS8Kr6O$308pSl^_+VzyQwO}oGL7f9B7eLh zzuCh^rgMKk&qtXWRE(}sOjJ+>*ItXv+~qIT4$8uQvk%=$l0LypWTXlAWhKA-62yMK z-9h}_t0lUN_TR*Xc-iFWmHEK3H$;AI;Qy7w%n z#WNZ{N^NoQ`Xsa7@Re!^G=>ZW?KtcC+f|T>fa-+1QpW-ftJZyl46=$Z`^%=NqLMB$ zwoP+nWY|fJz3vJxT3Q?ZDjjogTajSjeo6Y--C{?vYZ5ZcfTdc@DXnNqJZFgB`qEIH5 z9Q}W-<4A2m!CMEpJ1SeBDRpn|TCf&CP1f}wU(2|Jt~_-BIh!I~CCwvegXQ6;HSV_+ z6Q#$PQ^2?iNoP$fI=by@Ew49D!>?1Wa0YVoQE=ETaORq#kJ!qY`$o@!;yY=!!Rpa) zV3OuiR~;ClBvJRZ3CPx6tlzi8lD5ItD)-rrT=f-}uE!9(_LNbYdJ6HI04hAub|9O- zfXbu!w~H!9&q26s8mjgq0b>fNJ`3&G^*J<#AGV2M>+NlZf5m9tIOx$2Cat*OZH>dy zD0~!i^9Y#3(kNP4YBR4gwIq$#l>un%WIFqK52N2c?&{0%*|Ji5g=JmiEB1Oy_?ZG6 z1}}`@>g<+-BsDp&a61x7NO^J ztDt1Cq!F(Kw6^{;LN5}+4hy=T{tQ8}A7B2{>opGRRm$33hpHd+HaD!&BRV>{rXy6U!0mImc0fkZ&BxJIrOte1x5h0Kg7jQ+Vy= zmYWQZ1PE7EiJM1sGCf}y+{BfgSa9iXIkTTxFe_wcYHA48PdRP+RRTJGqupJf7It?` z-Bjr07v|qqZTMdqjmlz~%v=d{LI*W1OsZ&wLHPdZZ#pu=+lo$=1Er%ULVa6W%KJ`U zf0bnu3nET&1(ehuyi^Qo$^85WP#EAFFal9&F!h`@*1ECbOz_LY@WWTq27W8LV_JN1 zF9blDg7P+d>J0`tC$LET&ezSKqoAx*;1_;HRS;QKfq(K<&_59MkllrW%wl@xewp3#iA9R=I zXd=#mn~0?&>FK<7^_3%u6}-pg5LBMJ;}8=FcwlQI;s^%-tnq?Fi8hpYPo(h=wCYC2 zaJan{4b|P@x{6m!42{b2=Vgoi;*flN<*juu=$t#eG!mYK9;-c}3|PBbq(5o3I0Ls= z&feE#A!@(3zcujpqbSNvwFj@uP0cNBlQ!z|Om3T&N7Y61glclU+8FQHHstoU(-C~Dv_8tjwnPj zH7Qxg#HVM2^h2aYQ-KMFflD@b{Mtf{GiXHg<3xN(yPCwPPvJ?THzUm1LWzusLOYV{ zZ6iCZagT5`pDx9S+!_^q{%Yi1TUOCSRln6D|Ku;Y3T` z+nVeSyf7LFZ*m>`cN&`>8 zoDF2u!c}8x#J7-p55*})bfV?-27^pU2b?Kc+WKt0O4j!kFEWw$kb@2`Q!`xWOHSf^ zVji0TFYFjt8#q~OZSDYI2p?7<4oPAl*CAu1?QNscTCmD^zKfs3^x~41+V$>6$)ldI z>iBd2*W#m5@hNu)1$0z1;bmAn9b0^0JXr7wpnyD0=*Wx}4 zro7aCQlc^fOkYX%uT+w@U`YlSiuu;&CmhFQ_+Ri>>F-%(ZHC$1h3Am}y3SCrKMNhi z$?+1j#k=3?I8waS$}IRW)<)oy?Do`LCEPcbBcK?eR)G>u5`Wn@<<)0Mjz~3l>}P;W zaG7^~xbj|Ertvznql1^8hMo{l)eyva3`2WB(RWwi7c2U`mU!?G*%yi8_HIuIX5dOE z(%b8!{yWAj^kL;eI4o#_78kvFs|!5}FNyvG;7hLrrrImT(Jv5Y0Wrk%Rdik#h#iGw zT80rZ#tE>WF1@fwjF2-ZCWCJ*!Llpzf<^xonQb-X4SV(H$Sz>7!8KF`5j{`Ck^fKnM6x4dX?9f4Z=8U-(*qW=H!a7Jihb*-6mE+mQ)A>AAW_76>(l@i7 z%h8)O3UZkx&Hc{FN((S`swH+KZ9Cq-n45E6)H{nwnYm-&5%sPXh3fNBUb#PBOWI>< zB`s2PfvWzm32{Nh+pW>%Mi!d`w!i2IOrNI2N}9|_lKmX~U>sxj{)-}wp(~h&j#gAi zWUxo!ky|^x*Zc&u?LnsMUx9R?jQ?=Vlu74%_2ajmx*@cLz$M+nkO~iap2tx+`0KR7k1x|zymK2m? zp1Bp}H97nNg#7`$hsa9(0F3E0fLlke^AT5xte1cv=*k&GzfmSv+t)qYwnMS=JOYVh zd45k(L>EW94mqVVSUDOhJ}t}wDn(kblPh(;X~mORUVe&`V854N5$j2FlgQ2EAU7`P4kX+DkcK5N@g|W56M5T zH*l8XD9%Z}UYe_*b~FDdngvEDzNt7+b~OJ!R&lUO>(0b0llAHkisRuS(rd@28UFAQ zJjdp&p>we2A%NmmR#zf1-jOd)xGBrxBlgfZby3Hl!?4%MR>RYB%%O}TsVxPBo1~<6 z-z_)t#mdutAIlL|4F|#Rn}EKX0B)Sn>uV8ek{J`5;<>IdtnF&j+9&zQJ9k653ZTs7 zI^BxbLV^m6xqfUH*U;4IsFw7Ey$-Zcvvt?j0muQHp2yC!#(75oOsT*eZobUO?~)IK zPA~UR{j3W`JNmQY&pbvH4a-bIqYKPvG=MPO=nJotru6-yhv?5!hfMNJtHW%~TOR~% zC{OWdxpUKDP{WOGGS6T69?EH7tes%w55NT-El>L4a|AcG>&RF8Y$(B=t7yN-LA)Y! z9gjqxIiPP>BxnGQhB_k2>`B|wo(lOkRCB^M!FwMV*T$Q!SmQ^*eLt=!L~Ha5deQ!^ zg9L-$gIR@K2;1_}fPD=5qmmqWml(}b>9qgP#Il6Z5Gnl{@skiXH?{*{0FR!~g#5!L zk$K>m?p5~??Wi@Terb-Fe)SGoyQ zu~yNSIRDruD>>F_=AEix#S$O9u6)mnUqR4rCB0$SoC)x$u>}Upx8c;hGkQ>?Pn_~9 z!*uN9(Wq;4x{}RNp(g24>@2@1?ESEfOaSr*N)gJqL*m!&xXs`pHpO6kihZ4-V{mA_ zTRf2Vyfh6o6U#BkR z6_@c3t8=%9y!J6DKc>J7pW;i_j@gT|%)S*pJihdcgoZy3b%2lFZr^9$cQp1<*UD2v z5bb14+WOXr&{MiF)RbHnWZqp8#m74#L|tbJ0UbrCq+41>ADwzm%ZHPO)W3u9hHS&C zcF2&2?SU9pSEdH5^{CKT0|RpKoAOVTi)Q=af}26NT!Pknz4_o)mLJ0fsbS;&F!L~4 zbM~TFNR{P?zK8eu1{SdzT&$Gb6BE(f?jTwrdVzP>nv0N&KG*&L9x+~?6ZuAqKWJdv zw0)Azupsw;t0JUS(G<7B=NH}Pwk6L^luhv3M{{>ljg|ILnwc|NZ12OW^?RX%1eSu2 z2OL*fhb1~kI}66c7n>q+%F>QP&Vm{HUH%(8cf+t=hXVVoSLu}jLOFUUv4y&ea{mEV z$?2m^=*pQzcv42=X_URn(yE?QcHsvFoAkQ7*T||-2-BjxUU^wfVr%+52?jbse4O+| z!j91E0MSjb4OV22fm@!$541c?kLp0y_t@@V3;BL!V&n0BBX`367(Btl?VG7199MFB zUgv*`kqkzCwB??UC#=YT2Lj$4Blu~MII=R61*zH9ms8VF$dk_h{TN3i_)ORpb|@jH zpKxAN{#G`9`;yfM_$cVcL8_*u8SrIjVD-JKQU{S{kW#kB*RI!leTiJ)_brTG1X`xe zl7UGmhua;L1O`1>#7T!rJ16n4uf-cNMt*MhjW4#6M$jdPJz*G|>dnr2%TS-H?_VV~ z6}al!cq6}G{7w*@pC%#{$pd{ZijY8UMiT!}`Hv%LZ1tp-sc9$?-RiZvHx4co-Jk}A z9Tu#*Wb=b@L++voG%41>rMX+n^M*R=C0gX6A4P>+iZ6jS1Dj`}GN&u*|CqH>XwtHk zmb@Yo)cOeft$&MZf$&wJZStJctMa&(6XA8DBok&o9#K43{i>8fBFBQ8nx_Lf0deJram|)0%Mumiw&hoJ2k${Cg^_Atp6wc6MOAJ z)k>eUFJzPu_)@J%Btu0X(0b3c?%D9u;1a!BZ7V+0N2M>zuJV9 zls^j5y5_d4T-o%zDTVigkPBj_*ycp-U<>qLV7$g-ro;IpUa@nHvB%ae!T9aF7oue? zf}slK8lTp64#9dQ9C2{<=55B3C81+)g0f>ShYU$KE;kw1r0C8IM6sty#z2_Wn!`{( zzxb&m+{{gQ)*BxN1pdbbm8>^y!?`_vMcIf@QAaC9gTx9pEX0;g-sSnh7i3Ga(a)cG z-4(jfw%Z@Ep8e8TI*@r9Bb&a1{_eF>?lC1Sru({wPSem*v}A;-h?qBKZ&JL6i`F8|z= z1V6ggq6$~8vB;ejSebcefz^jky4SM|T> zbx%A%gg2?|ffx>I=50+EGE$|a-}QpYf)Gr|RnW^~Y+Nc|R2pi}3%XboYs06UFcAeFNBadk5pu({zr-n5!?w z(y{@RH=N(={pzpp8UQ(h4og9)+nbFEGkB3JH+9$w7qfLDp^&vzDxEApDXbWMbX>)9 zgnMpyNTUd%N9r4U+z!k{^k$rB8HT8gs<&5TTybXnCHJ%D>d7RU=FTeZLtks2DkbgQ zA-R(uQnOQ0s4gGcN&H!nO5-4SX=a%fL6xYsB4h^7Yj|6Vu`}B+PhhX4eMaBcD(3C& zPJB)L;ST1#p^EzOqb=2jR+IE~GYS{V;mHA?yc8{7qc%$nrfj_#pHI)mlSS$zS9L9z zPkfAy?`CG2161^c*9=n!q0Y&%GB&$zwr%$5KX;X#U=|TAXQ;nz(<<=rip+`Zoa453 zZbZUcoLo+)!L37O^8TOIqp|acuzY@pF?9Di`Td@%&_FGRTTbeo^HkaJlJNSfYv>_{ z%8Di!>*;qBnRnWhGS_=fQuFUzg2o5W{s|xz4VOF z46pb{Hg(z`fWW-3!oNN$ZZXLy=i6~~cu`^SM{|FF<(4+e5$g*ar&RH?O7i0JoC*n( zdUINB1RP+j9)9bh=rg*Ud0XaNK0ZaP?)Z1hleHDTDE*tzM{`G=QU??XD!i*aE|%a= zVA55TjT01(YwHPS$wE@Rme53sO0@gR>BcD4-~Fl{>X}1lz%=FIghk3J>$oTW0WgM~ zVh7=tKO+u$$Z>rig_!Oyy||S|?Qj&zlP<0M23PN%@GR|Es(JW2Up;T}S22|P4^ZMe z84{wPMCZ$#C-M|Tpz2}`EXpV3wCE3@ulDgOoDAi3bDKEumD$;>O4W_cN^64i4y;hH z8QjcC6qmQj>QE1lx3YY!Wxvf7zj#wxabjEttNaT2NgWNjF55j|g{J;`OC{8%J-s#S z-5)^f=XmENPG@p%h9&t|BlywA=%Yzs*F(}MdcMPTyv}t{xI_iE9*;<=+n(lbPw`Uh;32$+@fOi% zX!SlbwE!D=>rv<#&m%_)wV_={l_3!ZT5ev9fAv%^`@uCRtzGq057${HSO0r3Kq-7v z4OvQ6MPPv+Sz4jOblNG6w{bMzsy{*QAAmiqJa&43AIa4TE05WED5;7LidOQOh?CmA zpZ-9e#lj{^y8Y`78@TDd!+;pG-97E7nEhE;dEDg2VqWouprm<-*woa8f{#)*Erap! zMA45SWPmoYcfgK{n%dRFhmU3y2pD6uDF%_f|}<$lR7=rhB!e`ct*dYJ!gw|-%bnVu<<%s%((=G;43-W1oK?PC-oP zs#)k`9-8D`Fat#KyFiZ*39!x$FZJ7^+hRo-_|XF@hEmm&ki~4iTl)ge(+q5J7$ftJ zz15$QrcQciG=a)A_|hiekrSRE!}B{4!7fln?HcxAag~}57YKCdM$L)dLEs)KG@i$b z-kR|HW2wC<46scJ03Nx!M=Q)hTJOp0aCa(ZAZ|odFhy6+{C;0A{h_(Rg~+Gtv68n5 zx#tH5V-2!APHdeyBeO*Dh|mup65U;JlW}W9)v@Sd@@Tr5bt)9M1Xqc$`H?UWuN8^L zv=}%^@05rRL6A3l=m(tfwFv?!1!cPYMj!z5wXfa#<@4OO6zQwVhTD^nKr)^Up(G0> zVs|@ABfVuC*NI)@G0l(oX?{%+U6P&9L0adO9&wGXf28f=b%8U#TWCPe{EZb45Z#BK z^zu*F8Li7q61Uhkp`R9wovxj}Aw^~~c2tkgJ8TQ(^ZsrF=EIsX+#SjSF{ulT;zPYq zZ+cy`2~y6@ZG^GRS>ciUh@Mbxj!AI^hAcZWNe$!lOTX?-rE!r1hqz^%@2!5`8eRHQ zUr_D^>pA!z)cpJ5{u|zf@AJmR&V+@+=0@TGR!S;u&DnP91X~m(!(%hkkgPu49UCoy z3qSsAHx5M(z^Ec-e&!|~&oxs#<4x>FY;jc^@)b1wk0Yh}8<*H}v<(J>AD~%=6ok(E zfa-7;R-x^EQO-mlr%REFIs;uvelmUEjJFyu1i7khKMEd=PSU+lzGo09Ai$?G(3)7k zp0UXOIA^Z90VepakLupzw19Hf^mkAkBGeEVBcRfHa;p+F5$U_g=p}Ndbqk!>l`|TX z#JeZ?HkBv;I#CP1^{p>VqK~^u36a-4n|LyXw>-FlBwvs$nZ`!*~R)l*_8=At$-(DDNfh^0&b^v<4#<& z+38#4YGDLtSlG|egH^fZiTAsKyWp6pc2euC7iVUcIB44AP;6ha*$G3(YKrDA(UmqD z$yYs`Ib^E3@>ritpT}j;C^|CKMLw0n5G9QR2<*sy+?Zz1nyo!}ylGFQce%pJ{L9J4 z4xDyg7%_8=+0Zg2*a;W*$ho;ZJU)5kfLgG?7f5qN_ zxk?H>Z&bTMd%t#IPLD#p-qE+d>T$`` zSc#) zC~wmRk7{?9$D_y9qlWks{QhQ8s7j<`WCNBS%!so!8UF#0KXLIE2FiS6nwUK&mmH-R zS+GH6O^X+>w5G2Pv-9rQyD&o85xGTN5(+De3FfJpUc>s#bfVtB2Jj00?$l#04S5x)X^?mE! zfJ@JEqe(J0`*)hL)L;{Bc-3qCvm~`tdCtkvWpRx$4(=MQgIC$ll*J=|B^&);hWv}+O{?lL|w+cSW~f_g&shkb;! zSB-kgc1UYKLE2H$6DLTn_>7A?cRE7v? z(IgI1XV0xYB;30{!ga0>M(ffdNNio}t_A)P7(By~nZT|M-V zG)seJ@^skr_OQw{x(|}7Cp6k73Ov}&luVB5@C9lSNMmF=Na}a&Jb8A=Oa1(aZ67}K zHTrUg{RZ8lc3#V*ZNKI<(BGlm;!P$@>Yz%ynsSV0Iig#7l>>3YHg9&$cU%>io(?6U z-%OBdQd|nvWainqnT@Flq_a_->0icqW02JoF}er5`1^wOTCG5so%JQsZhgP3^OPWL z?GLF9U&N31?(w2+nv;dX6!eB9kX0h zZ2VNV6d2mz^5PVs9zpZh-cLX?lLnqa?8xxNQ;An^o1_1qx6u7PP#W|1sAfU}0(F31 Q{{N!|{sne_TYUNd0Mt=S^#A|> literal 0 HcmV?d00001 From 733f4e9ad054797b7cd6d3c94b747a403073238d Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 04:42:25 -0700 Subject: [PATCH 03/68] perf(sandbox): grant the run_code session lease in the reconnect instead of extra E2B round trips (#8621) * perf(sandbox): grant the run_code session lease in the reconnect instead of extra E2B round trips Reused Mothership workbench calls made five E2B control-plane requests: list, connect, getInfo + setTimeout on acquire (the set always fired), and getInfo on release. Connect now asks for max(5 min, remaining, lease), the handle records the deadline it requested, and acquisition/release skip the provider while that lower bound covers the request. Final deadlines are unchanged; unrequested deadlines are still read back. * test(sandbox): model connect as setting the deadline so a dropped preserve is caught --- .../remote-sandbox/e2b-session.test.ts | 75 +++++++++++++++++++ apps/sim/lib/execution/remote-sandbox/e2b.ts | 43 +++++++++-- .../sim/lib/execution/remote-sandbox/index.ts | 2 + .../remote-sandbox/session-sandbox.test.ts | 23 ++++++ .../lib/execution/remote-sandbox/session.ts | 6 +- .../sim/lib/execution/remote-sandbox/types.ts | 10 ++- 6 files changed, 150 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts b/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts index acce40ee82a..e3346f67063 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts @@ -606,3 +606,78 @@ describe('E2B session recovery', () => { expect(killSandbox).not.toHaveBeenCalled() }) }) + +describe('E2B session lease', () => { + const IDLE_MS = 20 * 60_000 + const LEASE_MS = IDLE_MS + 60_000 + + /** Counts control-plane requests; connect and setTimeout both set the deadline, so a caller must preserve it. */ + function controlPlane(remainingMs: number) { + const plane = { endAtMs: Date.now() + remainingMs, requests: 0 } + list.mockReturnValue({ nextItems, hasNext: false }) + nextItems.mockResolvedValue([{ ...candidate('retained', 10), endAt: new Date(plane.endAtMs) }]) + const sandbox = { + sandboxId: 'retained', + getInfo: async () => { + plane.requests++ + return { endAt: new Date(plane.endAtMs) } + }, + setTimeout: async (timeoutMs: number) => { + plane.requests++ + plane.endAtMs = Date.now() + timeoutMs + }, + } + connect.mockImplementation(async (_id: string, options: { timeoutMs: number }) => { + plane.requests++ + plane.endAtMs = Date.now() + options.timeoutMs + return sandbox + }) + create.mockImplementation(async (_template: string, options: { timeoutMs: number }) => { + plane.requests++ + plane.endAtMs = Date.now() + options.timeoutMs + return sandbox + }) + return plane + } + + it('grants a reused lease in the reconnect and keeps the idle window without more requests', async () => { + const plane = controlPlane(5 * 60_000) + const sandbox = await e2bProvider.findSessionSandbox?.('chat', { lifetimeMs: LEASE_MS }) + expect(plane.endAtMs).toBeGreaterThanOrEqual(Date.now() + LEASE_MS - 1000) + expect(sandbox?.outlives?.(IDLE_MS)).toBe(true) + await sandbox?.extendLifetime?.(IDLE_MS) + expect(plane.endAtMs).toBeGreaterThanOrEqual(Date.now() + IDLE_MS) + expect(plane.requests).toBe(1) + }) + + it('grants a created lease at creation without reading it back', async () => { + const plane = controlPlane(0) + const sandbox = await e2bProvider.create('mothership', { + sessionKey: 'chat', + lifetimeMs: LEASE_MS, + }) + expect(sandbox.outlives?.(IDLE_MS)).toBe(true) + await sandbox.extendLifetime?.(IDLE_MS) + expect(plane.endAtMs).toBeGreaterThanOrEqual(Date.now() + LEASE_MS - 1000) + expect(plane.requests).toBe(1) + }) + + it('keeps a later deadline another job already granted', async () => { + const plane = controlPlane(2 * 3_600_000) + const sandbox = await e2bProvider.findSessionSandbox?.('chat', { lifetimeMs: LEASE_MS }) + await sandbox?.extendLifetime?.(IDLE_MS) + expect(plane.endAtMs).toBeGreaterThanOrEqual(Date.now() + 2 * 3_600_000 - 1000) + expect(plane.requests).toBe(1) + }) + + it('reads back and extends a lease it did not grant itself', async () => { + const plane = controlPlane(5 * 60_000) + const sandbox = await e2bProvider.findSessionSandbox?.('chat', {}) + expect(sandbox?.outlives?.(IDLE_MS)).toBe(false) + await sandbox?.extendLifetime?.(LEASE_MS) + expect(plane.endAtMs).toBeGreaterThanOrEqual(Date.now() + LEASE_MS - 1000) + expect(plane.requests).toBe(3) + await sandbox?.extendLifetime?.(IDLE_MS) + expect(plane.requests).toBe(3) + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts index 7d6d5868933..91d8dce274d 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -359,25 +359,41 @@ class E2BSandboxHandle implements SandboxHandle { private killed = false private killPromise: Promise | null = null + /** + * @param sessionDeadlineAtMs Earliest time the provider can reap this session sandbox, as set + * by this handle's own create, connect, or timeout request. Session deadlines only ever move + * later — every update path extends and none shortens — so it stays a valid lower bound. + */ constructor( private readonly sandbox: E2BSandbox, private readonly language: CodeLanguage, private readonly providerLimitAtMs?: number, - private readonly sessionKey?: string + private readonly sessionKey?: string, + private sessionDeadlineAtMs?: number ) {} get sandboxId(): string { return this.sandbox.sandboxId } + outlives(lifetimeMs: number): boolean { + return ( + this.sessionDeadlineAtMs !== undefined && + this.sessionDeadlineAtMs >= Date.now() + e2bTimeoutMs(lifetimeMs) + ) + } + async extendLifetime(lifetimeMs: number): Promise { const timeoutMs = e2bTimeoutMs(lifetimeMs) if (this.sessionKey !== undefined) { + if (this.outlives(lifetimeMs)) return /** Session callers serialize updates so a short job cannot shorten another job's lease. */ const info = await this.sandbox.getInfo() if (info.endAt.getTime() >= Date.now() + timeoutMs) return } + const requestedAtMs = Date.now() await this.sandbox.setTimeout(timeoutMs) + if (this.sessionKey !== undefined) this.sessionDeadlineAtMs = requestedAtMs + timeoutMs } async runCode( @@ -1072,13 +1088,16 @@ export const e2bProvider: SandboxProvider = { effectiveLifetimeMs === E2B_MAX_SANDBOX_LIFETIME_MS ? lifetimeStartedAtMs + E2B_MAX_SANDBOX_LIFETIME_MS : undefined, - options?.sessionKey + options?.sessionKey, + options?.sessionKey && effectiveLifetimeMs !== undefined + ? lifetimeStartedAtMs + effectiveLifetimeMs + : undefined ) }, async findSessionSandbox( key: string, - options: { language?: CodeLanguage } + options: { language?: CodeLanguage; lifetimeMs?: number } ): Promise { const apiKey = env.E2B_API_KEY if (!apiKey) throw new Error('E2B_API_KEY is required when E2B is enabled') @@ -1097,9 +1116,21 @@ export const e2bProvider: SandboxProvider = { 'This workbench predates durable execution ownership and requires recovery before reuse' ) } - // Connect also sets a timeout, including for running sandboxes. Preserve the active deadline. - const timeoutMs = Math.max(5 * 60_000, candidate.endAt.getTime() - Date.now()) + // Connect also sets a timeout, including for running sandboxes. Preserve the active deadline, + // and grant the requested lease in the same request instead of a later getInfo + setTimeout. + const requestedAtMs = Date.now() + const timeoutMs = Math.max( + 5 * 60_000, + candidate.endAt.getTime() - requestedAtMs, + options.lifetimeMs === undefined ? 0 : e2bTimeoutMs(options.lifetimeMs) + ) const sandbox = await Sandbox.connect(candidate.sandboxId, { apiKey, timeoutMs }) - return new E2BSandboxHandle(sandbox, options.language ?? CodeLanguage.Python, undefined, key) + return new E2BSandboxHandle( + sandbox, + options.language ?? CodeLanguage.Python, + undefined, + key, + options.lifetimeMs === undefined ? undefined : requestedAtMs + timeoutMs + ) }, } diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index c4aef7154b2..2823dab8a72 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -135,6 +135,8 @@ async function leaseSandbox( created, session: status, release: async () => { + // A deadline that already covers the idle window needs no serialized update. + if (created.sandbox.outlives?.(SESSION_SANDBOX_IDLE_MS)) return // Cleanup failure cannot relabel a completed mutation as a failed execution. try { await withSandboxSessionLock(session.key, AbortSignal.timeout(30_000), async () => { diff --git a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts index 0e18b1b07b6..72974fa56f5 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts @@ -771,6 +771,29 @@ describe('session sandbox lease', () => { expect(calls.extendLifetime.length).toBeGreaterThanOrEqual(2) }) + it('trusts a lease granted by a slow reconnect for the whole call, then refreshes nothing', async () => { + const { handle, calls } = fakeSandbox('sb-covered') + let grantedUntilMs = 0 + handle.outlives = (lifetimeMs) => grantedUntilMs >= Date.now() + lifetimeMs + mockFindSessionSandbox.mockImplementation( + async (_key: string, options: { lifetimeMs?: number }) => { + grantedUntilMs = Date.now() + (options.lifetimeMs ?? 0) + await sleep(20) + return handle + } + ) + + const result = await executeInSandbox({ + ...CODE_REQUEST, + sandboxKind: 'mothership', + session: { key: 'mothership-chat:c3' }, + }) + + expect(result.sandboxSession).toBe('reused') + expect(grantedUntilMs).toBeGreaterThanOrEqual(Date.now() + 20 * 60_000) + expect(calls.extendLifetime).toHaveLength(0) + }) + it('does not rewrite an unchanged executable while earlier code can still use it', async () => { const { handle } = fakeSandbox('unchanged-tooling') mockFindSessionSandbox.mockResolvedValue(handle) diff --git a/apps/sim/lib/execution/remote-sandbox/session.ts b/apps/sim/lib/execution/remote-sandbox/session.ts index 6dd0656aaab..370180e105c 100644 --- a/apps/sim/lib/execution/remote-sandbox/session.ts +++ b/apps/sim/lib/execution/remote-sandbox/session.ts @@ -35,8 +35,10 @@ export async function ensureSessionSandbox(args: { signal.throwIfAborted() if (!provider.findSessionSandbox) throw new Error('This deployment has no persistent workbench') const lifetimeMs = SESSION_SANDBOX_IDLE_MS + (options.lifetimeMs ?? 0) + const requestedAtMs = Date.now() const existing = await provider.findSessionSandbox(session.key, { ...(options.language ? { language: options.language } : {}), + lifetimeMs, }) signal.throwIfAborted() const created: CreatedSandbox = existing @@ -61,7 +63,9 @@ export async function ensureSessionSandbox(args: { providerId: provider.id, sandboxId: created.sandbox.sandboxId, }) - await created.sandbox.extendLifetime?.(lifetimeMs) + // The budget is anchored before acquisition, so a lease granted by the lookup or create covers it. + if (!created.sandbox.outlives?.(Math.max(0, lifetimeMs - (Date.now() - requestedAtMs)))) + await created.sandbox.extendLifetime?.(lifetimeMs) signal.throwIfAborted() if (session.cli) { await ensureSessionCli(created.sandbox, session.cli, signal, args.bootstrapTimeoutMs) diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index bdcbc8194cb..21271b3ee19 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -278,6 +278,11 @@ export interface SandboxHandle { * served an execution. Absent on providers without session support. */ extendLifetime?(lifetimeMs: number): Promise + /** + * True when a deadline this handle already established keeps the sandbox alive for + * `lifetimeMs` from now, so {@link extendLifetime} would make no provider request. + */ + outlives?(lifetimeMs: number): boolean /** Reads provider metadata without materializing the file contents. */ getFileSize(path: string): Promise readFile(path: string): Promise @@ -475,10 +480,11 @@ export interface SandboxProvider { * {@link CreateSandboxOptions.sessionKey}, or resolves null when none is * available. Lookup failures must throw rather than masquerade as absence. * Providers without session support omit this method; callers then - * run every execution in a fresh sandbox. + * run every execution in a fresh sandbox. `lifetimeMs` asks the reconnect to keep the + * sandbox alive at least that long, never shortening a later deadline. */ findSessionSandbox?( key: string, - options: { language?: CodeLanguage } + options: { language?: CodeLanguage; lifetimeMs?: number } ): Promise } From dc10d4c2775729ce1dbf5549541489ca1f78ac31 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 07:26:41 -0700 Subject: [PATCH 04/68] fix(chat): label simple effort options with the effort they send (#8619) The simple Chat effort picker labeled medium as Low, high as Medium, and xhigh as High. Derive its options from MOTHERSHIP_EFFORT_OPTIONS so each label names the effort it sends: Medium, High, Extra High. Values, the default (high), and the stored preference are unchanged. --- apps/sim/lib/mothership/model-options.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/mothership/model-options.ts b/apps/sim/lib/mothership/model-options.ts index 19cb2473e12..c3f28dc3607 100644 --- a/apps/sim/lib/mothership/model-options.ts +++ b/apps/sim/lib/mothership/model-options.ts @@ -17,12 +17,12 @@ export const MOTHERSHIP_MODEL_OPTIONS = [ { value: 'claude-opus-5-5', label: 'Opus 5.5' }, ] satisfies Array<{ value: ModelSelection['model']; label: string }> -/** Labels deliberately describe the simplified product dial; values are provider efforts. */ -export const MOTHERSHIP_SIMPLE_EFFORT_OPTIONS: Array<{ value: MothershipEffort; label: string }> = [ - { value: 'medium', label: 'Low' }, - { value: 'high', label: 'Medium' }, - { value: 'xhigh', label: 'High' }, -] +const SIMPLE_EFFORT_VALUES: ReadonlySet = new Set(['medium', 'high', 'xhigh']) + +/** The efforts the simple picker offers, labeled with the effort each one sends. */ +export const MOTHERSHIP_SIMPLE_EFFORT_OPTIONS = MOTHERSHIP_EFFORT_OPTIONS.filter((option) => + SIMPLE_EFFORT_VALUES.has(option.value) +) export function mothershipEffortOptions(model: ModelSelection['model']) { return model === 'gpt-6-sol' From 807523eddac25ee49481faeadfe59fb2b75fc358 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 09:01:48 -0700 Subject: [PATCH 05/68] feat(workflows): stop a manual v2 run after a block, and `workflows run --stop-after` (#8622) * feat(workflows): stop a manual v2 run after a block, and `workflows run --stop-after` A manual v2 run can now name `run.stopAfterBlockId`; the run stops once that block completes and downstream blocks do not execute. Combined with a block entry on the same block, it re-runs exactly one block against a prior run's persisted upstream outputs, server-side: sim workflows run W --from-block X --source-run R --stop-after X --select-output X.result Agents verifying an edit no longer re-run every upstream block (often a slow LLM or API call) or toggle blocks off to skip them. - Contract: optional `stopAfterBlockId` on the manual run selection. - Application: both manual operations refuse a block missing from the saved workflow or nested in a loop/parallel (the engine would otherwise run to the end or stop after one iteration), before anything runs. - Execute service: threads the trusted value to the sync and stream paths. - CLI: `--stop-after ` implies --manual and rejects --async. - E2E: test-workflow-stop-after-e2e.ts against a running app; the http-e2e job gains a Redis service because hosted billing admits runs through a Redis usage reservation. * fix(workflows): refuse stop targets a run cannot reach, and run the E2E self-hosted - The manual operations refuse a stop block the run cannot reach from its entry (an upstream block would let the run finish everything after the entry), and look blocks up as own properties. - The executor fails a run whose stop block is absent from the workflow it executes, instead of running everything; this closes the window between validation and the executor's own draft load, for every caller. - The CLI refuses an empty --stop-after rather than dropping it. - CI: the stop-after E2E gets its own self-hosted app step; the SCIM suite asserts PostgreSQL rate-limit storage, so Redis is not added to that app. Fixture cleanup waits for run logs to finalize before deleting. * fix(workflows): refuse a disabled stop block, or one reached only through one The executor omits disabled blocks from its graph, so a disabled stop target, or one whose only path runs through a disabled block, is never reached and the run would finish everything after the entry. * fix(workflows): the executor refuses a disabled stop block too The serialized workflow keeps disabled blocks, but the DAG skips them, so a disabled stop target would never be reached. --------- Co-authored-by: Waleed Latif --- .github/workflows/test-build.yml | 45 ++ apps/docs/content/docs/cli/reference.mdx | 1 + apps/docs/content/docs/cli/workflows.mdx | 1 + apps/docs/openapi-v2-workflows.json | 16 + .../workflows/[workflowId]/execute/route.ts | 2 + apps/sim/lib/api/contracts/v2/workflows.ts | 14 + .../execute-manual-workflow.test.ts | 118 +++++ .../application/execute-manual-workflow.ts | 67 +++ .../lib/workflows/executor/execute-service.ts | 8 + .../lib/workflows/executor/execution-core.ts | 8 + apps/sim/package.json | 1 + .../scripts/test-workflow-stop-after-e2e.ts | 410 ++++++++++++++++++ .../protocol/workflow-run-follow.test.ts | 55 +++ .../commands/protocol/workflow-run-follow.ts | 35 +- packages/sim-cli/src/generated/v2-api.ts | 1 + scripts/check-unused-exports.baseline.json | 2 - 16 files changed, 769 insertions(+), 15 deletions(-) create mode 100644 apps/sim/scripts/test-workflow-stop-after-e2e.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e6ef55b6ae2..98abe3bf0ab 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -253,6 +253,51 @@ jobs: VERSION_COMPARE_E2E_REPORT_PATH="$report_dir/version-compare-http-report.json" \ bun run test:workflow-version-compare:e2e + # A self-hosted app: hosted billing admits a run only through a Redis usage + # reservation, and the SCIM suite above asserts PostgreSQL rate-limit storage, + # so workflow execution gets its own app rather than adding Redis to that one. + - name: Verify single-block workflow runs over real HTTP + working-directory: apps/sim + env: + NEXT_PUBLIC_APP_URL: http://127.0.0.1:3018 + BETTER_AUTH_URL: http://127.0.0.1:3018 + NEXT_PUBLIC_FORCE_HOSTED: 'false' + INTERNAL_API_SECRET: stop-after-http-ci-local-secret-at-least-32-characters + DB_TX_TRIPWIRE: throw + DISABLE_TELEMETRY: 'true' + NEXT_TELEMETRY_DISABLED: '1' + NEXT_PUBLIC_CHAT_DISABLED: 'true' + READY_TIMEOUT_SECONDS: 300 + run: | + report_dir="$RUNNER_TEMP/e2e" + server_log="$report_dir/stop-after-next.log" + mkdir -p "$report_dir" + node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3018 > "$server_log" 2>&1 & + server_pid=$! + finish() { + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$report_dir/stop-after-http-status.log" + } + trap finish EXIT + fail_startup() { + echo "::error::$1" + tail -n 200 "$server_log" + exit 1 + } + started=$SECONDS + until curl --fail --silent --max-time 10 http://127.0.0.1:3018/api/health > /dev/null; do + kill -0 "$server_pid" 2>/dev/null || fail_startup 'Local workflow app exited during startup.' + [ $((SECONDS - started)) -lt "$READY_TIMEOUT_SECONDS" ] || + fail_startup "Local workflow app did not become ready within $READY_TIMEOUT_SECONDS seconds." + sleep 2 + done + echo "Local workflow app ready after $((SECONDS - started))s" + STOP_AFTER_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ + STOP_AFTER_E2E_DATABASE_URL="$DATABASE_URL" \ + STOP_AFTER_E2E_REPORT_PATH="$report_dir/stop-after-http-report.json" \ + bun run test:workflow-stop-after:e2e + - name: Upload end-to-end reports and server logs if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index f7e2a05d058..80cadfee795 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -6704,6 +6704,7 @@ sim workflows run [options] | `--mock-payload` | No | Use the selected trigger's server-derived mock payload; runs the current saved workflow state (implies --manual). | | `--from-block ` | No | Run manually from this saved workflow block. | | `--source-run ` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). | +| `--stop-after ` | No | Stop the run after this saved block; with --from-block on the same block, re-runs only that block (implies --manual). | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | | `--include-thinking` | No | Show model reasoning while following (requires --follow). | | `--include-tool-calls` | No | Show tool calls while following (requires --follow). | diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 359f2be643a..dc239167f2f 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -642,6 +642,7 @@ sim workflows run [options] | `--mock-payload` | No | Use the selected trigger's server-derived mock payload; runs the current saved workflow state (implies --manual). | | `--from-block ` | No | Run manually from this saved workflow block. | | `--source-run ` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). | +| `--stop-after ` | No | Stop the run after this saved block; with --from-block on the same block, re-runs only that block (implies --manual). | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | | `--include-thinking` | No | Show model reasoning while following (requires --follow). | | `--include-tool-calls` | No | Show tool calls while following (requires --follow). | diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 4d843361eb6..2d34a84cd65 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -12615,6 +12615,11 @@ "additionalProperties": false } ] + }, + "stopAfterBlockId": { + "description": "Saved workflow block after which the run stops; downstream blocks do not execute. Must not be inside a loop or parallel. With a block entry naming the same block, re-runs only that block against the source run.", + "type": "string", + "minLength": 1 } }, "required": ["source"], @@ -12703,6 +12708,17 @@ "sourceRunId": "run_123" } } + }, + { + "run": { + "source": "manual", + "entry": { + "type": "block", + "blockId": "block_123", + "sourceRunId": "run_123" + }, + "stopAfterBlockId": "block_123" + } } ] }, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index 0bb2bc272a2..b1a5a8e9c44 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -448,6 +448,7 @@ export const POST = withRouteHandler( mode: body.stream ? 'stream' : resultStream ? 'sync-result-stream' : 'sync', blockId: manualRun.entry.blockId, sourceRunId: manualRun.entry.sourceRunId, + stopAfterBlockId: manualRun.stopAfterBlockId, }, request: req, }) @@ -460,6 +461,7 @@ export const POST = withRouteHandler( mode: body.stream ? 'stream' : resultStream ? 'sync-result-stream' : 'sync', triggerBlockId: manualRun.entry?.blockId, useMockPayload: manualRun.entry?.useMockPayload === true, + stopAfterBlockId: manualRun.stopAfterBlockId, }, request: req, }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 7b3c4812b99..917d935556f 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1297,6 +1297,13 @@ export const v2WorkflowRunSelectionSchema = z.discriminatedUnion('source', [ .describe( 'Manual entry mode. Omit to enter through the workflow trigger; a block entry requires an exact source run.' ), + stopAfterBlockId: z + .string() + .min(1, 'run.stopAfterBlockId cannot be empty') + .optional() + .describe( + 'Saved workflow block after which the run stops; downstream blocks do not execute. Must not be inside a loop or parallel. With a block entry naming the same block, re-runs only that block against the source run.' + ), }) .strict(), ]) @@ -1412,6 +1419,13 @@ export const v2ExecuteWorkflowBodySchema = z entry: { type: 'block', blockId: 'block_123', sourceRunId: 'run_123' }, }, }, + { + run: { + source: 'manual', + entry: { type: 'block', blockId: 'block_123', sourceRunId: 'run_123' }, + stopAfterBlockId: 'block_123', + }, + }, ], }) export type V2ExecuteWorkflowBody = z.input diff --git a/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts b/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts index ce403f1dabd..200241c8656 100644 --- a/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts +++ b/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts @@ -300,6 +300,124 @@ describe('manual workflow execution application operations', () => { expect(mocks.loadSourceState).not.toHaveBeenCalled() }) + it('rejects a stop block missing from the saved workflow before anything runs', async () => { + await expect( + executeManualWorkflowOperation.execute({ + principal, + input: { ...baseInput, useMockPayload: false, stopAfterBlockId: 'missing' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: expect.stringContaining('not a block') }) + await expect( + executeManualWorkflowFromBlockOperation.execute({ + principal, + input: { + ...baseInput, + blockId: 'agent-1', + sourceRunId: 'source-run-1', + stopAfterBlockId: 'missing', + }, + }) + ).rejects.toMatchObject({ code: 'validation', message: expect.stringContaining('not a block') }) + expect(mocks.loadSourceState).not.toHaveBeenCalled() + expect(mocks.executeService).not.toHaveBeenCalled() + }) + + it('rejects a stop block nested in a loop or parallel, which the engine cannot stop on', async () => { + mockLoadManualState.mockResolvedValue({ + blocks: { + 'trigger-1': {}, + 'loop-1': { type: 'loop' }, + 'agent-1': { data: { parentId: 'loop-1' } }, + }, + edges: [], + }) + + await expect( + executeManualWorkflowOperation.execute({ + principal, + input: { ...baseInput, useMockPayload: false, stopAfterBlockId: 'agent-1' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: expect.stringContaining('inside loop') }) + expect(mocks.executeService).not.toHaveBeenCalled() + }) + + it('rejects a stop block the run cannot reach from its entry, which would run everything', async () => { + mockLoadManualState.mockResolvedValue({ + blocks: { 'trigger-1': {}, 'agent-1': {}, 'agent-2': {}, unconnected: {} }, + edges: [ + { source: 'trigger-1', target: 'agent-1' }, + { source: 'agent-1', target: 'agent-2' }, + ], + }) + + await expect( + executeManualWorkflowFromBlockOperation.execute({ + principal, + input: { + ...baseInput, + blockId: 'agent-2', + sourceRunId: 'source-run-1', + stopAfterBlockId: 'agent-1', + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('not reachable'), + }) + await expect( + executeManualWorkflowOperation.execute({ + principal, + input: { ...baseInput, useMockPayload: false, stopAfterBlockId: 'unconnected' }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('not reachable'), + }) + expect(mocks.loadSourceState).not.toHaveBeenCalled() + expect(mocks.executeService).not.toHaveBeenCalled() + }) + + it('rejects a disabled stop block, or one reached only through a disabled block', async () => { + mockLoadManualState.mockResolvedValue({ + blocks: { + 'trigger-1': {}, + 'agent-1': { enabled: false }, + 'agent-2': {}, + }, + edges: [ + { source: 'trigger-1', target: 'agent-1' }, + { source: 'agent-1', target: 'agent-2' }, + ], + }) + + await expect( + executeManualWorkflowOperation.execute({ + principal, + input: { ...baseInput, useMockPayload: false, stopAfterBlockId: 'agent-1' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: expect.stringContaining('is disabled') }) + await expect( + executeManualWorkflowOperation.execute({ + principal, + input: { ...baseInput, useMockPayload: false, stopAfterBlockId: 'agent-2' }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('not reachable'), + }) + expect(mocks.executeService).not.toHaveBeenCalled() + }) + + it('rejects a stop block named by an inherited object key', async () => { + await expect( + executeManualWorkflowOperation.execute({ + principal, + input: { ...baseInput, useMockPayload: false, stopAfterBlockId: 'toString' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: expect.stringContaining('not a block') }) + expect(mocks.executeService).not.toHaveBeenCalled() + }) + it('rejects a source run without persisted state for this workflow', async () => { mocks.loadSourceState.mockResolvedValueOnce(null) diff --git a/apps/sim/lib/workflows/application/execute-manual-workflow.ts b/apps/sim/lib/workflows/application/execute-manual-workflow.ts index 840e89d5108..4d7049e7161 100644 --- a/apps/sim/lib/workflows/application/execute-manual-workflow.ts +++ b/apps/sim/lib/workflows/application/execute-manual-workflow.ts @@ -20,6 +20,7 @@ interface ManualExecutionInput extends Omit { input?: unknown mode: 'sync' | 'stream' | 'sync-result-stream' + stopAfterBlockId?: string } export interface ExecuteManualWorkflowInput extends ManualExecutionInput { @@ -47,6 +48,69 @@ async function loadManualState(workflowId: string) { return state } +type ManualWorkflowState = Awaited> + +/** Blocks a run entering at `entryBlockId` can reach; the executor skips disabled blocks. */ +function reachableFrom(state: ManualWorkflowState, entryBlockId: string): Set { + const targetsBySource = new Map() + for (const edge of state.edges) { + const targets = targetsBySource.get(edge.source) ?? [] + targets.push(edge.target) + targetsBySource.set(edge.source, targets) + } + const reached = new Set([entryBlockId]) + const queue = [entryBlockId] + for (let next = queue.pop(); next !== undefined; next = queue.pop()) { + for (const target of targetsBySource.get(next) ?? []) { + if (reached.has(target) || state.blocks[target]?.enabled === false) continue + reached.add(target) + queue.push(target) + } + } + return reached +} + +/** + * The engine stops only when it completes a node whose id equals the target, so + * a target the run cannot reach would silently run everything after the entry: + * an unknown or disabled block, a block upstream of the entry or only behind a + * disabled one, or a block inside a loop or parallel (which would stop after its + * first iteration or never). All are refused, matching the editor, which offers + * "Run until block" only outside subflows. + */ +function assertStopAfterBlock( + state: ManualWorkflowState, + blockId: string | undefined, + entryBlockId: string +): void { + if (blockId === undefined) return + const block = Object.hasOwn(state.blocks, blockId) ? state.blocks[blockId] : undefined + if (!block) { + throw new OrchestrationError( + 'validation', + `run.stopAfterBlockId "${blockId}" is not a block in the current saved workflow.` + ) + } + if (block.enabled === false) { + throw new OrchestrationError( + 'validation', + `run.stopAfterBlockId "${blockId}" is disabled, so the run never executes it.` + ) + } + if (block.data?.parentId) { + throw new OrchestrationError( + 'validation', + `run.stopAfterBlockId "${blockId}" is inside loop or parallel "${block.data.parentId}"; stop after that container instead.` + ) + } + if (!reachableFrom(state, entryBlockId).has(blockId)) { + throw new OrchestrationError( + 'validation', + `run.stopAfterBlockId "${blockId}" is not reachable from entry block "${entryBlockId}"; stop after the entry block or one downstream of it.` + ) + } +} + function listTriggers(options: ReturnType): string { return options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') } @@ -68,6 +132,7 @@ function executionServiceInput(params: { includeFileBase64: params.input.includeFileBase64, base64MaxBytes: params.input.base64MaxBytes, selectedOutputs: params.input.selectedOutputs, + stopAfterBlockId: params.input.stopAfterBlockId, rateLimitCounter: 'sync' as const, abortSignal: params.input.abortSignal, mode: params.input.mode, @@ -115,6 +180,7 @@ export const executeManualWorkflowOperation = defineAuthorizedWorkflowUseCase({ ) } + assertStopAfterBlock(state, input.stopAfterBlockId, selected.triggerBlockId) const executionInput = input.useMockPayload ? selected.mockPayload : input.input const validation = validateTriggerInput(selected, executionInput) if (!validation.ok) { @@ -141,6 +207,7 @@ export const executeManualWorkflowFromBlockOperation = defineAuthorizedWorkflowU `run.entry.blockId "${input.blockId}" is not a block in the current saved workflow.` ) } + assertStopAfterBlock(state, input.stopAfterBlockId, input.blockId) const sourceSnapshot = await getExecutionStateForWorkflow(input.sourceRunId, context.workflowId) if (!sourceSnapshot) { diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index d76c07f6595..dec05aac944 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -120,6 +120,8 @@ export interface ExecuteWorkflowServiceParams { /** Mocked upstream outputs (block name/id → output object) overlaid on the snapshot. */ variableInputs?: Record } + /** Saved block after which execution stops, validated by the application use case. */ + stopAfterBlockId?: string } export interface ExecuteWorkflowServiceFailure { @@ -272,6 +274,7 @@ export async function executeWorkflowService( useDraftState = false, triggerBlockId, runFromBlock, + stopAfterBlockId, } = params let reqLogger = logger.withMetadata({ requestId, workflowId, userId }) @@ -289,6 +292,9 @@ export async function executeWorkflowService( if (runFromBlock && !useDraftState) { throw new Error('Run-from-block requires manual execution state') } + if (stopAfterBlockId && !useDraftState) { + throw new Error('Stop-after-block requires manual execution state') + } if (callChain) { const chainError = validateCallChain(callChain) @@ -589,6 +595,7 @@ export async function executeWorkflowService( triggerBlockId, useDraftState, runFromBlock, + stopAfterBlockId, onStream, onBlockComplete: (blockId, data) => onBlockComplete(blockId, data.output, data.outputBlockId), @@ -689,6 +696,7 @@ export async function executeWorkflowService( base64MaxBytes, abortSignal: timeoutController.signal, runFromBlock, + stopAfterBlockId, }) await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 0f994a481f6..a5b10390c5d 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -907,6 +907,14 @@ async function executeWorkflowCoreImpl( resolvedStopAfterBlockId = buildLoopSentinelEndId(stopAfterBlockId) } else if (serializedWorkflow.parallels?.[stopAfterBlockId]) { resolvedStopAfterBlockId = buildParallelSentinelEndId(stopAfterBlockId) + } else if ( + !serializedWorkflow.blocks.some((block) => block.id === stopAfterBlockId && block.enabled) + ) { + // The engine stops on an exact node id and skips disabled blocks, so an absent or + // disabled target would run everything. + throw new Error( + `Stop block ${stopAfterBlockId} is not an enabled block in the workflow being executed` + ) } } diff --git a/apps/sim/package.json b/apps/sim/package.json index f537910f724..cbb1ef66c78 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -23,6 +23,7 @@ "test": "vitest run", "test:scim:e2e": "bun run scripts/test-scim-e2e.ts", "test:workflow-version-compare:e2e": "bun --no-env-file scripts/test-workflow-version-compare-e2e.ts", + "test:workflow-stop-after:e2e": "bun --no-env-file scripts/test-workflow-stop-after-e2e.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "email:dev": "email dev --dir components/emails", diff --git a/apps/sim/scripts/test-workflow-stop-after-e2e.ts b/apps/sim/scripts/test-workflow-stop-after-e2e.ts new file mode 100644 index 00000000000..691583fa980 --- /dev/null +++ b/apps/sim/scripts/test-workflow-stop-after-e2e.ts @@ -0,0 +1,410 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import postgres from 'postgres' +import { + type V2ExecuteWorkflowBody, + type V2ExecuteWorkflowData, + v2ExecuteWorkflowDataSchema, +} from '@/lib/api/contracts/v2/workflows' +import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' + +/** + * Exercises `run.stopAfterBlockId` against a running local Next app, through the + * v2 execute route and the published CLI, on a disposable database. + * + * The fixture is `Start → Slow (wait) → Check (wait) → After (wait)`. Slow stands + * in for an expensive upstream block, so a single-block re-run of Check that + * finishes well under Slow's delay proves Slow was not re-executed, and an + * absent After output proves the run stopped where it was told to. + */ +const logger = createLogger('WorkflowStopAfterE2E') +const execFileAsync = promisify(execFile) +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +/** The first execute request cold-compiles the route under `next dev`. */ +const REQUEST_TIMEOUT_MS = 300_000 +const SLOW_SECONDS = 4 +const SLOW_MS = SLOW_SECONDS * 1000 +const startedAt = new Date().toISOString() + +function requiredEnvironment(name: string): string { + const value = process.env[name] + assert(value, `${name} must be explicitly provided`) + return value +} + +const baseUrl = new URL(requiredEnvironment('STOP_AFTER_E2E_BASE_URL')) +const databaseUrl = assertDisposableTestDatabaseUrl( + requiredEnvironment('STOP_AFTER_E2E_DATABASE_URL') +) +const reportPath = requiredEnvironment('STOP_AFTER_E2E_REPORT_PATH') +assert(new Set(['localhost', '127.0.0.1', '[::1]']).has(baseUrl.hostname), 'Use a loopback app') +assert.equal(baseUrl.protocol, 'http:', 'Use a local HTTP app') +assert(!baseUrl.username && !baseUrl.password, 'App URL cannot contain credentials') +assert.equal(baseUrl.pathname, '/', 'App URL must be an origin') +assert(!baseUrl.search && !baseUrl.hash, 'App URL cannot contain a query or fragment') + +const sql = postgres(databaseUrl.toString(), { max: 2 }) +const ownerId = generateId() +const workspaceId = generateId() +const personalKey = `sk-sim-fixture-${generateId()}` +const cliPath = fileURLToPath(new URL('../../../packages/sim-cli/src/index.ts', import.meta.url)) +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +const requests: { method: string; path: string; status: number; durationMs: number }[] = [] +let directory: string | undefined + +interface PipelineFixture { + workflowId: string + start: string + slow: string + check: string + after: string +} + +const pipeline = fixtureIds() +const otherPipeline = fixtureIds() + +function fixtureIds(): PipelineFixture { + return { + workflowId: generateId(), + start: generateId(), + slow: generateId(), + check: generateId(), + after: generateId(), + } +} + +function failureMessage(error: unknown): string { + return truncate(getErrorMessage(error).replaceAll(personalKey, '[redacted]'), 2000) +} + +async function check(name: string, run: () => Promise) { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) }) + logger.info(`PASS ${name}`) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: failureMessage(error), + }) + throw error + } +} + +function record(value: unknown): Record { + assert(isRecordLike(value), 'Expected a JSON object') + return value +} + +async function seedPipeline(tx: postgres.TransactionSql, fixture: PipelineFixture) { + await tx`insert into workflow (id, user_id, workspace_id, name, last_synced, created_at, updated_at) + values (${fixture.workflowId}, ${ownerId}, ${workspaceId}, ${`Stop-after fixture ${fixture.workflowId}`}, now(), now(), now())` + const wait = (seconds: number) => ({ + timeValue: { id: 'timeValue', type: 'short-input', value: String(seconds) }, + timeUnit: { id: 'timeUnit', type: 'dropdown', value: 'seconds' }, + async: { id: 'async', type: 'switch', value: false }, + }) + const blocks = [ + { + id: fixture.start, + type: 'start_trigger', + name: 'Start', + subBlocks: { inputFormat: { id: 'inputFormat', type: 'input-format', value: [] } }, + }, + { id: fixture.slow, type: 'wait', name: 'Slow', subBlocks: wait(SLOW_SECONDS) }, + { id: fixture.check, type: 'wait', name: 'Check', subBlocks: wait(0.2) }, + { id: fixture.after, type: 'wait', name: 'After', subBlocks: wait(0.2) }, + ] + for (const [index, block] of blocks.entries()) { + await tx`insert into workflow_blocks (id, workflow_id, type, name, position_x, position_y, sub_blocks) + values (${block.id}, ${fixture.workflowId}, ${block.type}, ${block.name}, ${index * 300}, 0, ${JSON.stringify(block.subBlocks)}::text::jsonb)` + } + for (const [source, target] of [ + [fixture.start, fixture.slow], + [fixture.slow, fixture.check], + [fixture.check, fixture.after], + ]) { + await tx`insert into workflow_edges (id, workflow_id, source_block_id, target_block_id, source_handle, target_handle) + values (${generateId()}, ${fixture.workflowId}, ${source}, ${target}, 'source', 'target')` + } +} + +async function seed() { + directory = await mkdtemp(resolve(tmpdir(), 'sim-stop-after-')) + await sql.begin(async (tx) => { + const email = `${ownerId}@stop-after.test` + await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at) + values (${ownerId}, 'Stop-after fixture', ${email}, ${email}, true, now(), now())` + await tx`insert into user_stats (id, user_id) values (${generateId()}, ${ownerId})` + await tx`insert into workspace (id, name, owner_id, billed_account_user_id) + values (${workspaceId}, 'Stop-after fixture', ${ownerId}, ${ownerId})` + await tx`insert into permissions (id, user_id, entity_type, entity_id, permission_type) + values (${generateId()}, ${ownerId}, 'workspace', ${workspaceId}, 'admin')` + await tx`insert into api_key (id, user_id, name, key, key_hash, type) + values (${generateId()}, ${ownerId}, 'Stop-after fixture', ${personalKey}, ${sha256Hex(personalKey)}, 'personal')` + await seedPipeline(tx, pipeline) + await seedPipeline(tx, otherPipeline) + }) +} + +async function execute( + workflowId: string, + body: V2ExecuteWorkflowBody, + expectedStatus = 200 +): Promise> { + const url = new URL(`/api/v2/workflows/${workflowId}/execute`, baseUrl) + const started = performance.now() + // boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP + const response = await fetch(url, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'x-api-key': personalKey, + 'x-forwarded-for': '127.0.0.1', + }, + body: JSON.stringify(body), + }) + requests.push({ + method: 'POST', + path: url.pathname, + status: response.status, + durationMs: Math.round(performance.now() - started), + }) + const text = await readResponseTextWithLimit(response, { + maxBytes: MAX_RESPONSE_BYTES, + label: 'Stop-after E2E response', + }) + assert.equal(response.status, expectedStatus, `${url.pathname}: ${truncate(text, 500)}`) + return record(JSON.parse(text)) +} + +async function run( + workflowId: string, + body: V2ExecuteWorkflowBody +): Promise { + const data = v2ExecuteWorkflowDataSchema.parse(record(await execute(workflowId, body)).data) + assert.equal(data.status, 'completed', `run ${data.runId} did not complete`) + return data +} + +async function expectBadRequest(workflowId: string, body: V2ExecuteWorkflowBody, code: string) { + const status = code === 'NOT_FOUND' ? 404 : 400 + const error = record((await execute(workflowId, body, status)).error) + assert.equal(error.code, code) +} + +async function runCli(args: string[]): Promise { + assert(directory, 'CLI fixture directory must exist') + const { stdout } = await execFileAsync( + 'bun', + [ + '--no-env-file', + cliPath, + '--endpoint', + baseUrl.origin, + '--workspace', + workspaceId, + '--output', + 'json', + 'workflows', + 'run', + ...args, + ], + { + cwd: directory, + env: { ...process.env, SIM_CONFIG_DIR: directory, SIM_API_KEY: personalKey, NO_COLOR: '1' }, + timeout: REQUEST_TIMEOUT_MS, + maxBuffer: MAX_RESPONSE_BYTES, + } + ) + return v2ExecuteWorkflowDataSchema.parse(JSON.parse(stdout)) +} + +const selectAll = ['Slow.status', 'Check.status', 'After.status'] + +try { + await check('seed disposable workspace, personal key and two pipelines', seed) + + let sourceRunId = '' + await check('a full manual run executes every block and persists its state', async () => { + const full = await run(pipeline.workflowId, { + run: { source: 'manual' }, + selectedOutputs: selectAll, + }) + assert.deepEqual(full.blockOutputs, { + 'Slow.status': 'completed', + 'Check.status': 'completed', + 'After.status': 'completed', + }) + assert( + (full.durationMs ?? 0) >= SLOW_MS, + `a full run must include Slow's ${SLOW_MS} ms, took ${full.durationMs} ms` + ) + sourceRunId = full.runId + }) + + await check( + 'the CLI re-runs only Check against the source run, without re-running Slow', + async () => { + const single = await runCli([ + pipeline.workflowId, + '--from-block', + pipeline.check, + '--source-run', + sourceRunId, + '--stop-after', + pipeline.check, + ...selectAll.flatMap((selector) => ['--select-output', selector]), + ]) + assert.equal(single.status, 'completed') + assert.notEqual(single.runId, sourceRunId) + assert.deepEqual(single.blockOutputs, { 'Check.status': 'completed' }) + assert( + (single.durationMs ?? Number.POSITIVE_INFINITY) < SLOW_MS, + `a single-block run must not wait on Slow, took ${single.durationMs} ms` + ) + } + ) + + await check('a trigger-entry run stops after the named block', async () => { + const until = await run(pipeline.workflowId, { + run: { source: 'manual', stopAfterBlockId: pipeline.slow }, + selectedOutputs: selectAll, + }) + assert.deepEqual(until.blockOutputs, { 'Slow.status': 'completed' }) + }) + + await check('a block entry without stopAfterBlockId still runs downstream blocks', async () => { + const fromCheck = await run(pipeline.workflowId, { + run: { + source: 'manual', + entry: { type: 'block', blockId: pipeline.check, sourceRunId }, + }, + selectedOutputs: selectAll, + }) + assert.deepEqual(fromCheck.blockOutputs, { + 'Check.status': 'completed', + 'After.status': 'completed', + }) + }) + + await check('invalid stop-after selections are refused before anything runs', async () => { + const before = + await sql`select count(*)::int as count from workflow_execution_logs where workflow_id = ${pipeline.workflowId}` + await expectBadRequest( + pipeline.workflowId, + { run: { source: 'manual', stopAfterBlockId: otherPipeline.check } }, + 'BAD_REQUEST' + ) + await expectBadRequest( + pipeline.workflowId, + { run: { source: 'manual', stopAfterBlockId: '' } }, + 'BAD_REQUEST' + ) + await expectBadRequest( + pipeline.workflowId, + { + run: { + source: 'manual', + entry: { type: 'block', blockId: pipeline.check, sourceRunId }, + stopAfterBlockId: pipeline.slow, + }, + }, + 'BAD_REQUEST' + ) + await expectBadRequest( + pipeline.workflowId, + { run: { source: 'manual', stopAfterBlockId: pipeline.check }, async: true }, + 'BAD_REQUEST' + ) + await expectBadRequest( + pipeline.workflowId, + { + run: { + source: 'manual', + entry: { type: 'block', blockId: pipeline.check, sourceRunId: 'not-a-run' }, + stopAfterBlockId: pipeline.check, + }, + }, + 'NOT_FOUND' + ) + const after = + await sql`select count(*)::int as count from workflow_execution_logs where workflow_id = ${pipeline.workflowId}` + assert.equal(after[0].count, before[0].count, 'a refused request must not start a run') + }) + + await check('a source run from another workflow is not accepted', async () => { + const foreign = await run(otherPipeline.workflowId, { run: { source: 'manual' } }) + await expectBadRequest( + pipeline.workflowId, + { + run: { + source: 'manual', + entry: { type: 'block', blockId: pipeline.check, sourceRunId: foreign.runId }, + stopAfterBlockId: pipeline.check, + }, + }, + 'NOT_FOUND' + ) + }) +} catch (error) { + logger.error(failureMessage(error)) + process.exitCode = 1 +} finally { + try { + await check('remove disposable fixtures', async () => { + // A response returns before its run finishes persisting logs and large-value + // references; a cascade delete racing those writes can be chosen as a deadlock victim. + const workflowIds = [pipeline.workflowId, otherPipeline.workflowId] + for (let attempt = 0; attempt < 120; attempt++) { + const [{ open }] = + await sql`select count(*)::int as open from workflow_execution_logs where workflow_id in ${sql(workflowIds)} and ended_at is null` + if (open === 0) break + await sleep(500) + } + for (let attempt = 1; ; attempt++) { + try { + await sql.begin(async (tx) => { + await tx`delete from workspace where id = ${workspaceId}` + await tx`delete from "user" where id = ${ownerId}` + }) + break + } catch (error) { + const deadlocked = isRecordLike(error) && error.code === '40P01' + if (!deadlocked || attempt === 5) throw error + await sleep(1000) + } + } + if (directory) await rm(directory, { recursive: true, force: true }) + }) + } catch (error) { + logger.error(failureMessage(error)) + process.exitCode = 1 + } finally { + await sql.end() + await writeFile( + reportPath, + `${JSON.stringify({ startedAt, finishedAt: new Date().toISOString(), status: process.exitCode ? 'failed' : 'passed', checks, requests }, null, 2)}\n` + ) + } +} diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts index 9033de264cf..d0d54c207f2 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts @@ -203,6 +203,61 @@ describe('sim workflows run --follow', () => { }) }) + it('sends --stop-after with a block entry so one block re-runs against the source run', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await run( + WORKFLOW_ID, + '--from-block', + 'agent-1', + '--source-run', + 'run-1', + '--stop-after', + 'agent-1' + ) + + expect(requestRaw.mock.calls[0][1].body).toEqual({ + run: { + source: 'manual', + entry: { type: 'block', blockId: 'agent-1', sourceRunId: 'run-1' }, + stopAfterBlockId: 'agent-1', + }, + }) + }) + + it('lets --stop-after alone imply a manual run through the trigger', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await run(WORKFLOW_ID, '--stop-after', 'agent-1') + await run(WORKFLOW_ID, '--trigger', 'start', '--stop-after', 'agent-1') + + expect(requestRaw.mock.calls[0][1].body).toEqual({ + run: { source: 'manual', stopAfterBlockId: 'agent-1' }, + }) + expect(requestRaw.mock.calls[1][1].body).toEqual({ + run: { + source: 'manual', + entry: { type: 'trigger', blockId: 'start' }, + stopAfterBlockId: 'agent-1', + }, + }) + }) + + it('refuses an empty --stop-after rather than running the whole draft', async () => { + await expect(run(WORKFLOW_ID, '--stop-after', '')).rejects.toThrow( + '--stop-after requires a block ID' + ) + expect(requestRaw).not.toHaveBeenCalled() + }) + + it('refuses --stop-after with --async before sending anything', async () => { + await expect(run(WORKFLOW_ID, '--stop-after', 'agent-1', '--async')).rejects.toThrow( + 'Manual execution does not support --async' + ) + expect(request).not.toHaveBeenCalled() + expect(requestRaw).not.toHaveBeenCalled() + }) + it('prints then fails for a failed NDJSON run just like the JSON path', async () => { requestRaw.mockResolvedValue( ndjsonResponse({ diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index da5c9001848..00f8ca36807 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -43,16 +43,13 @@ export interface FollowOptions { stderr: CommentaryWriter } -type WorkflowRunSelection = - | { source: 'manual' } - | { - source: 'manual' - entry: { type: 'trigger'; blockId?: string; useMockPayload?: boolean } - } - | { - source: 'manual' - entry: { type: 'block'; blockId: string; sourceRunId: string } - } +type WorkflowRunSelection = { + source: 'manual' + entry?: + | { type: 'trigger'; blockId?: string; useMockPayload?: boolean } + | { type: 'block'; blockId: string; sourceRunId: string } + stopAfterBlockId?: string +} /** Projects friendly CLI flags into the API's strict nested run selector. */ export function resolveWorkflowRunSelection( @@ -60,8 +57,10 @@ export function resolveWorkflowRunSelection( ): WorkflowRunSelection | undefined { const trigger = typeof flags.trigger === 'string' ? flags.trigger : undefined const useMockPayload = flags.mockPayload === true - /** A trigger entry only exists on the draft, so these flags imply `--manual`. */ - const manual = flags.manual === true || trigger !== undefined || useMockPayload + const stopAfter = typeof flags.stopAfter === 'string' ? flags.stopAfter : undefined + /** A trigger entry and a stop block only exist on the draft, so these flags imply `--manual`. */ + const manual = + flags.manual === true || trigger !== undefined || useMockPayload || stopAfter !== undefined const fromBlock = typeof flags.fromBlock === 'string' ? flags.fromBlock : undefined const sourceRun = typeof flags.sourceRun === 'string' ? flags.sourceRun : undefined @@ -80,15 +79,20 @@ export function resolveWorkflowRunSelection( if (useMockPayload && flags.input !== undefined) { throw new SimApiError('--mock-payload cannot be combined with --input', 0) } + if (stopAfter !== undefined && stopAfter.trim() === '') { + throw new SimApiError('--stop-after requires a block ID', 0) + } + const stop = stopAfter !== undefined ? { stopAfterBlockId: stopAfter } : {} if (fromBlock && sourceRun) { return { source: 'manual', entry: { type: 'block', blockId: fromBlock, sourceRunId: sourceRun }, + ...stop, } } if (!manual) return undefined - if (!trigger && !useMockPayload) return { source: 'manual' } + if (!trigger && !useMockPayload) return { source: 'manual', ...stop } return { source: 'manual', entry: { @@ -96,6 +100,7 @@ export function resolveWorkflowRunSelection( ...(trigger ? { blockId: trigger } : {}), ...(useMockPayload ? { useMockPayload: true } : {}), }, + ...stop, } } @@ -488,6 +493,10 @@ export function attachWorkflowRunFollow(workflows: Command): void { '--source-run ', 'Prior run whose persisted state supplies upstream outputs (requires --from-block)' ) + .option( + '--stop-after ', + 'Stop the run after this saved block; with --from-block on the same block, re-runs only that block (implies --manual)' + ) .option( '--follow', 'Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns' diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index c6a15f56e57..aad83e0a553 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -4893,6 +4893,7 @@ export type ExecuteWorkflowBody = { blockId: string sourceRunId: string } + stopAfterBlockId?: string } async?: boolean executionTimeoutSeconds?: number diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index f238aa01c30..013b0444c84 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -3808,8 +3808,6 @@ "apps/sim/lib/api/contracts/v2/workflows.ts#V2DeployedWebhook", "apps/sim/lib/api/contracts/v2/workflows.ts#V2DownloadRunFileParams", "apps/sim/lib/api/contracts/v2/workflows.ts#V2DuplicateWorkflowBody", - "apps/sim/lib/api/contracts/v2/workflows.ts#V2ExecuteWorkflowBody", - "apps/sim/lib/api/contracts/v2/workflows.ts#V2ExecuteWorkflowData", "apps/sim/lib/api/contracts/v2/workflows.ts#V2ExecuteWorkflowHeaders", "apps/sim/lib/api/contracts/v2/workflows.ts#V2ExecuteWorkflowQueued", "apps/sim/lib/api/contracts/v2/workflows.ts#V2ExecutionError", From b2c5b76263d87a196eced84bccbacdff11d4e783 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 09:05:41 -0700 Subject: [PATCH 06/68] fix(executor): drop the full JSON clone of execution state at run completion (#8620) * fix(executor): stop retaining duplicate copies of loop block outputs * test(executor): guard output sharing in block logs and loop aggregates * fix(logs): size execution data the way JSON.stringify writes it * fix(logs): count JSON string bytes without copying and unbox primitive wrappers * fix(logs): measure execution data iteratively and apply toJSON on functions * fix(executor): drop the full JSON clone of execution state at run completion * fix(executor): normalize only live state for PII masking and walk serializability lazily * fix(executor): snapshot array lengths and unbox wrappers in JSON walks * fix(logs): read boxed boolean and bigint values the way JSON.stringify does --- apps/sim/executor/execution/block-executor.ts | 9 +- apps/sim/executor/execution/engine.ts | 23 +++- .../execution/snapshot-serializer.test.ts | 85 +++++++++++- .../executor/execution/snapshot-serializer.ts | 110 +++++++++++++++- apps/sim/executor/execution/snapshot.ts | 11 +- apps/sim/executor/utils/output-filter.test.ts | 13 ++ apps/sim/lib/core/utils/bounded-json.ts | 7 +- .../lib/execution/payloads/serializer.test.ts | 16 +++ apps/sim/lib/execution/payloads/serializer.ts | 33 ++++- apps/sim/lib/execution/payloads/store.test.ts | 21 +++ apps/sim/lib/execution/payloads/store.ts | 16 ++- .../lib/logs/execution/json-byte-size.test.ts | 60 +++++++++ apps/sim/lib/logs/execution/json-byte-size.ts | 121 ++++++++++++++++++ apps/sim/lib/logs/execution/logger.ts | 97 +++----------- .../logs/execution/trace-spans/trace-spans.ts | 41 +++++- 15 files changed, 551 insertions(+), 112 deletions(-) create mode 100644 apps/sim/lib/logs/execution/json-byte-size.test.ts create mode 100644 apps/sim/lib/logs/execution/json-byte-size.ts diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index d3780253a93..0623ffa828f 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -424,9 +424,8 @@ export class BlockExecutor { typeof normalizedOutput._childWorkflowInstanceId === 'string' ? normalizedOutput._childWorkflowInstanceId : undefined - const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { - block, - }) + // Shallow top-level copy: streaming later writes token/cost keys onto blockLog.output. + const displayOutput = { ...blockLog.output } const displayInput = this.projectInputsForDisplay(inputsForLog, block, inputDisplayRegistry) blockLog.input = displayInput const displayProvenance = settledBlockRegistry?.exportCommittedProvenanceForValue({ @@ -680,7 +679,7 @@ export class BlockExecutor { if (!isSentinel && blockLog) { const displayInput = this.projectInputsForDisplay(input, block, inputDisplayRegistry) - const displayOutput = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) + const displayOutput = { ...blockLog.output } const displayProvenance = ctx.resolvedSecretTraceRegistry?.exportCommittedProvenanceForValue({ input: displayInput, @@ -798,7 +797,7 @@ export class BlockExecutor { const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error) ? error.childWorkflowInstanceId : undefined - const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) + const displayOutput = { ...blockLog.output } const displayInput = this.projectInputsForDisplay(input, block, inputDisplayRegistry) const displayProvenance = errorRegistry?.exportCommittedProvenanceForValue({ input: displayInput, diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index b6091f395e7..86f696694cd 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -5,7 +5,10 @@ import { subscribeToExecutionCancellation } from '@/lib/execution/cancellation' import { BlockType, EDGE } from '@/executor/constants' import type { DAG } from '@/executor/dag/builder' import type { EdgeManager } from '@/executor/execution/edge-manager' -import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer' +import { + buildCompletedExecutionState, + serializePauseSnapshot, +} from '@/executor/execution/snapshot-serializer' import type { SerializableExecutionState } from '@/executor/execution/types' import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import type { @@ -147,7 +150,7 @@ export class ExecutionEngine { success: true, output: this.finalOutput, logs: this.context.blockLogs, - executionState: this.getSerializableExecutionState(), + executionState: this.getCompletedExecutionState(), metadata: this.context.metadata, } } catch (error) { @@ -591,6 +594,22 @@ export class ExecutionEngine { } } + /** + * State for a run whose blocks have all settled, without the JSON round-trip. + * Cancelled and failed runs keep {@link getSerializableExecutionState}: a block + * still running there could mutate the logs after the run returns. + */ + private getCompletedExecutionState(): SerializableExecutionState | undefined { + try { + return buildCompletedExecutionState(this.context, this.dag, this.edgeManager) + } catch (error) { + this.execLogger.warn('Failed to serialize execution state', { + error: toError(error).message, + }) + return undefined + } + } + private collectPauseResponses(): NormalizedBlockOutput { const responses = Array.from(this.pausedBlocks.values()).map((pause) => pause.response) diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index f2c855fd661..b2482ed1eff 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { DAG, DAGNode } from '@/executor/dag/builder' import { EdgeManager } from '@/executor/execution/edge-manager' -import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer' +import { + buildCompletedExecutionState, + isLiveExecutionState, + serializePauseSnapshot, +} from '@/executor/execution/snapshot-serializer' import type { ExecutionContext } from '@/executor/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -307,3 +311,82 @@ describe('serializePauseSnapshot', () => { expect(serialized.metadata.capabilityGovernedUserId).toBe('requesting-member') }) }) + +describe('buildCompletedExecutionState', () => { + /** The state a completed run used to carry: the pause snapshot's state, JSON round-tripped. */ + function jsonClonedState(context: ExecutionContext): unknown { + return JSON.parse(serializePauseSnapshot(context, []).snapshot).state + } + + function contextWithOutput(output: unknown): ExecutionContext { + return createContext({ + blockStates: new Map([['block-1', { output, executed: true, executionTime: 5 }]]), + executedBlocks: new Set(['block-1']), + blockLogs: [ + { + blockId: 'block-1', + blockName: 'Block', + blockType: 'function', + startedAt: '2026-01-01T00:00:00.000Z', + endedAt: '2026-01-01T00:00:01.000Z', + durationMs: 1000, + success: true, + executionOrder: 1, + output, + input: { note: undefined, list: [undefined, 1] }, + }, + ] as ExecutionContext['blockLogs'], + }) + } + + const shared = { rows: [{ id: 1, at: new Date(0) }] } + const cyclic: Record = { a: 1 } + cyclic.self = cyclic + const cycleBehindToJSON: Record = { toJSON: () => ({ safe: true }) } + cycleBehindToJSON.self = cycleBehindToJSON + + it.each([ + ['a subtree shared twice', { first: shared, second: shared }], + ['a cycle hidden behind toJSON', { value: cycleBehindToJSON }], + [ + 'a boxed number carrying a BigInt property', + { value: Object.assign(Object(1), { big: BigInt(1) }) }, + ], + ])('serializes like the JSON-cloned pause state for %s', (_name, output) => { + const context = contextWithOutput(output) + expect(JSON.stringify(buildCompletedExecutionState(context))).toBe( + JSON.stringify(jsonClonedState(context)) + ) + }) + + it.each([ + ['a cycle', cyclic], + ['a BigInt', { big: BigInt(1) }], + ])('throws like the pause snapshot for %s', (_name, output) => { + const context = contextWithOutput(output) + expect(() => jsonClonedState(context)).toThrow(TypeError) + expect(() => buildCompletedExecutionState(context)).toThrow(TypeError) + }) + + it('shares block outputs with the run instead of cloning them', () => { + const output = { rows: [{ id: 1 }] } + const context = contextWithOutput(output) + const state = buildCompletedExecutionState(context) + + expect(state.blockLogs[0].output).toBe(output) + expect(state.blockStates['block-1'].output).toBe(output) + + context.blockLogs[0].endedAt = 'later' + expect(state.blockLogs[0].endedAt).toBe('2026-01-01T00:00:01.000Z') + }) + + it('marks completed state as live through spreads but not through JSON', () => { + const context = contextWithOutput({ rows: [{ id: 1 }] }) + const state = buildCompletedExecutionState(context) + + expect(isLiveExecutionState(state)).toBe(true) + expect(isLiveExecutionState({ ...state, sourceExecutionId: 'other' })).toBe(true) + expect(isLiveExecutionState(jsonClonedState(context))).toBe(false) + expect(JSON.stringify(state)).toBe(JSON.stringify(jsonClonedState(context))) + }) +}) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index 784502a1bc5..ec58b61adf5 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -183,12 +183,12 @@ function serializeParallelExecutions( return result } -export function serializePauseSnapshot( +function buildExecutionSnapshot( context: ExecutionContext, triggerBlockIds: string[], dag?: DAG, edgeManager?: EdgeManager -): SerializedSnapshot { +): { snapshot: ExecutionSnapshot; state: SerializableExecutionState } { const metadataFromContext = context.metadata as ExecutionMetadata | undefined let useDraftState: boolean if (metadataFromContext?.useDraftState !== undefined) { @@ -316,9 +316,113 @@ export function serializePauseSnapshot( context.selectedOutputs, state ) + return { snapshot, state } +} +export function serializePauseSnapshot( + context: ExecutionContext, + triggerBlockIds: string[], + dag?: DAG, + edgeManager?: EdgeManager +): SerializedSnapshot { return { - snapshot: snapshot.toJSON(), + snapshot: buildExecutionSnapshot(context, triggerBlockIds, dag, edgeManager).snapshot.toJSON(), triggerIds: triggerBlockIds, } } + +const LIVE_EXECUTION_STATE = Symbol('liveExecutionState') + +/** + * Throws exactly where `JSON.stringify(value)` would — a cycle or a BigInt, + * after applying `toJSON` — without building the string. Iterative, so nesting + * that native serialization handles cannot overflow the JS stack here. + */ +function assertJsonSerializable(value: unknown): void { + type Frame = { node: object; keys: string[] | undefined; length: number; index: number } + const ancestors = new Set() + const stack: Frame[] = [] + + const enter = (raw: unknown, key: string): void => { + let current = raw + if ( + (typeof current === 'object' && current !== null) || + typeof current === 'function' || + typeof current === 'bigint' + ) { + const toJSON = (current as { toJSON?: unknown }).toJSON + if (typeof toJSON === 'function') current = toJSON.call(current, key) + } + if (typeof current === 'bigint' || current instanceof BigInt) { + throw new TypeError('Do not know how to serialize a BigInt') + } + if (typeof current !== 'object' || current === null) return + // Serialized as their primitive value; their own properties are never read. + if (current instanceof Number || current instanceof String || current instanceof Boolean) { + return + } + if (ancestors.has(current)) { + throw new TypeError('Converting circular structure to JSON') + } + ancestors.add(current) + const keys = Array.isArray(current) ? undefined : Object.keys(current) + stack.push({ + node: current, + keys, + length: keys ? keys.length : (current as unknown[]).length, + index: 0, + }) + } + + enter(value, '') + while (stack.length > 0) { + const frame = stack[stack.length - 1] + if (frame.index >= frame.length) { + ancestors.delete(frame.node) + stack.pop() + continue + } + const index = frame.index++ + const key = frame.keys ? frame.keys[index] : String(index) + enter((frame.node as Record)[key], key) + } +} + +/** + * Execution state for a run whose blocks have all settled. Validates and fails + * exactly like `JSON.parse(serializePauseSnapshot(...).snapshot).state`, but + * skips that full JSON clone: block logs and block states are shallow copies + * sharing their inputs and outputs with the run, and JSON normalization is left + * to whoever serializes the state. + */ +export function buildCompletedExecutionState( + context: ExecutionContext, + dag?: DAG, + edgeManager?: EdgeManager +): SerializableExecutionState { + const { snapshot, state } = buildExecutionSnapshot(context, [], dag, edgeManager) + assertJsonSerializable(snapshot.toSerializable()) + const completed: SerializableExecutionState = { + ...state, + blockLogs: state.blockLogs.map((log) => ({ ...log })), + blockStates: Object.fromEntries( + Object.entries(state.blockStates).map(([blockId, blockState]) => [blockId, { ...blockState }]) + ), + } + // Enumerable so object spreads carry it; JSON serialization ignores symbol keys. + Object.defineProperty(completed, LIVE_EXECUTION_STATE, { value: true, enumerable: true }) + return completed +} + +/** + * Whether execution state came from {@link buildCompletedExecutionState} (or a + * spread of it) and so still holds live, not-yet-JSON-normalized values. + * Other states came out of a JSON round-trip already. + */ +export function isLiveExecutionState(state: unknown): boolean { + return ( + typeof state === 'object' && + state !== null && + (state as Record)[LIVE_EXECUTION_STATE] === true + ) +} diff --git a/apps/sim/executor/execution/snapshot.ts b/apps/sim/executor/execution/snapshot.ts index 675c3d5aaad..a79dda51325 100644 --- a/apps/sim/executor/execution/snapshot.ts +++ b/apps/sim/executor/execution/snapshot.ts @@ -69,8 +69,9 @@ export class ExecutionSnapshot { this.state = state } - toJSON(): string { - return JSON.stringify({ + /** The value {@link toJSON} stringifies, built without serializing it. */ + toSerializable(): Record { + return { metadata: { ...this.metadata, principal: serializePrincipal(this.metadata.principal), @@ -81,7 +82,11 @@ export class ExecutionSnapshot { workflowVariables: this.workflowVariables, selectedOutputs: this.selectedOutputs, state: this.state, - }) + } + } + + toJSON(): string { + return JSON.stringify(this.toSerializable()) } static fromJSON(json: string): ExecutionSnapshot { diff --git a/apps/sim/executor/utils/output-filter.test.ts b/apps/sim/executor/utils/output-filter.test.ts index 786cc3ffa17..9d260c5f076 100644 --- a/apps/sim/executor/utils/output-filter.test.ts +++ b/apps/sim/executor/utils/output-filter.test.ts @@ -33,4 +33,17 @@ describe('output filtering', () => { expect(output).not.toHaveProperty('childTraceSpans') expect(output.answer).toBe(42) }) + + it('shares untouched nested output with the block state instead of copying it', () => { + const rows = [{ id: 1, data: { name: 'a' } }] + const nestedSpans = { childTraceSpans: [{ id: 's1' }], kept: { value: 1 } } + const blockOutput = { rows, nested: nestedSpans } + + const output = filterOutputForLog('table', blockOutput as never) + + expect(output.rows).toBe(rows) + expect(output.nested).not.toBe(nestedSpans) + expect(output.nested).not.toHaveProperty('childTraceSpans') + expect((output.nested as typeof nestedSpans).kept).toBe(nestedSpans.kept) + }) }) diff --git a/apps/sim/lib/core/utils/bounded-json.ts b/apps/sim/lib/core/utils/bounded-json.ts index c0522d0ddc5..c3a304b2ba8 100644 --- a/apps/sim/lib/core/utils/bounded-json.ts +++ b/apps/sim/lib/core/utils/bounded-json.ts @@ -1,8 +1,11 @@ const MAX_JSON_NODES = 100_000 const MAX_JSON_DEPTH = 64 -/** Counts JSON escapes without allocating the escaped string. */ -function quotedStringBytes(value: string, remaining: number): number | undefined { +/** + * UTF-8 byte length of `JSON.stringify(value)` for a string, counted without + * allocating the escaped copy. Returns `undefined` once it exceeds `remaining`. + */ +export function quotedStringBytes(value: string, remaining: number): number | undefined { let bytes = 2 for (let index = 0; index < value.length && bytes <= remaining; index++) { const code = value.charCodeAt(index) diff --git a/apps/sim/lib/execution/payloads/serializer.test.ts b/apps/sim/lib/execution/payloads/serializer.test.ts index ab4ebea813c..f0ba04e2a00 100644 --- a/apps/sim/lib/execution/payloads/serializer.test.ts +++ b/apps/sim/lib/execution/payloads/serializer.test.ts @@ -258,6 +258,22 @@ describe('compactExecutionPayload', () => { expect(compacted.every(isLargeArrayManifest)).toBe(true) }) + it('reuses already-compacted subflow entries instead of rebuilding them', async () => { + const unchanged = { rows: [{ id: 1, data: { name: 'a' } }], meta: { count: 1 } } + const file = { id: 'f1', key: 'k1', url: 'u', name: 'a.txt', size: 3, type: 'text/plain' } + const withBase64 = { file: { ...file, base64: 'YWJj' }, other: { kept: true } } + + const [reused, stripped] = (await compactSubflowResults([unchanged, withBase64], {})) as [ + typeof unchanged, + typeof withBase64, + ] + + expect(reused).toBe(unchanged) + expect(stripped).not.toBe(withBase64) + expect(stripped.file).toEqual(file) + expect(stripped.other).toBe(withBase64.other) + }) + it('rejects durable compaction when storage context is incomplete', async () => { await expect( compactExecutionPayload( diff --git a/apps/sim/lib/execution/payloads/serializer.ts b/apps/sim/lib/execution/payloads/serializer.ts index 7d7680a3429..b6f47e2a1f2 100644 --- a/apps/sim/lib/execution/payloads/serializer.ts +++ b/apps/sim/lib/execution/payloads/serializer.ts @@ -27,6 +27,12 @@ export interface CompactExecutionPayloadOptions extends LargeValueStoreContext { interface CompactState { seen: WeakSet + /** + * Return an unchanged plain object or array as-is instead of rebuilding it. + * Only for input that `compactBlockOutput` already rebuilt: sharing a raw + * handler output would retain V8's heavier `JSON.parse` object shapes. + */ + reuseUnchanged?: boolean } const BLOCK_LOG_COMPACTION_CONCURRENCY = 4 @@ -149,19 +155,32 @@ async function compactEntries( } if (Array.isArray(value)) { - return Promise.all(value.map((item) => compactValue(item, options, state, depth + 1))) + const compactedItems = await Promise.all( + value.map((item) => compactValue(item, options, state, depth + 1)) + ) + return state.reuseUnchanged && + Object.getPrototypeOf(value) === Array.prototype && + compactedItems.every((item, index) => item === value[index]) + ? value + : compactedItems } - return Object.fromEntries( - await Promise.all( - Object.entries(value).map(async ([key, entryValue]) => [ + const entries = Object.entries(value) + const compactedEntries = await Promise.all( + entries.map( + async ([key, entryValue]): Promise<[string, unknown]> => [ key, key === 'finalBlockLogs' && Array.isArray(entryValue) ? await compactBlockLogs(entryValue as BlockLog[], options) : await compactValue(entryValue, options, state, depth + 1), - ]) + ] ) ) + return state.reuseUnchanged && + Object.getPrototypeOf(value) === Object.prototype && + compactedEntries.every(([, compacted], index) => compacted === entries[index][1]) + ? value + : Object.fromEntries(compactedEntries) } async function compactEntriesWithEarlyReject( @@ -245,7 +264,9 @@ export async function compactSubflowResults( ): Promise { const entryOptions = { ...options, preserveRoot: false } let compactedResults = (await Promise.all( - results.map((result) => compactExecutionPayload(result, entryOptions)) + results.map((result) => + compactValue(result, entryOptions, { seen: new WeakSet(), reuseUnchanged: true }) + ) )) as T[] const aggregate = getJsonAndSize({ results: compactedResults }) diff --git a/apps/sim/lib/execution/payloads/store.test.ts b/apps/sim/lib/execution/payloads/store.test.ts index 0695945d607..0e4bac3a88c 100644 --- a/apps/sim/lib/execution/payloads/store.test.ts +++ b/apps/sim/lib/execution/payloads/store.test.ts @@ -353,6 +353,27 @@ describe('large execution payload store', () => { expect(materializeLargeValueRefSync(ref, { executionId: 'execution-1' })).toBeUndefined() }) + it('keeps a durably stored trace archive out of the in-process cache', async () => { + const context = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + userId: 'user-1', + } + const archiveRef = await storeExecutionTraceArchive( + { traceSpans: [] }, + '{"traceSpans":[]}', + 17, + context + ) + const valueRef = await storeLargeValue({ rows: [] }, '{"rows":[]}', 11, context) + + expect(materializeLargeValueRefSync(archiveRef, { executionId: 'execution-1' })).toBeUndefined() + expect(materializeLargeValueRefSync(valueRef, { executionId: 'execution-1' })).toEqual({ + rows: [], + }) + }) + it('rejects archives above the trace cap before upload or metadata writes', async () => { await expect( storeExecutionTraceArchive({}, '{}', MAX_TRACE_ARCHIVE_BYTES + 1, { diff --git a/apps/sim/lib/execution/payloads/store.ts b/apps/sim/lib/execution/payloads/store.ts index 7b67fa8094d..0aedf8bc19e 100644 --- a/apps/sim/lib/execution/payloads/store.ts +++ b/apps/sim/lib/execution/payloads/store.ts @@ -171,7 +171,12 @@ export async function storeLargeValue( return persistLargeValue(value, json, size, context, MAX_DURABLE_LARGE_VALUE_BYTES) } -/** Stores a completed execution archive with a larger cap than individual workflow values. */ +/** + * Stores a completed execution archive with a larger cap than individual + * workflow values. Not kept in the in-process cache: the run is over, readers + * materialize it asynchronously from storage, and the archive can hold live + * run objects rather than their JSON form. + */ export async function storeExecutionTraceArchive( value: Record, json: string, @@ -183,7 +188,8 @@ export async function storeExecutionTraceArchive( json, size, { ...context, requireDurable: true }, - MAX_TRACE_ARCHIVE_BYTES + MAX_TRACE_ARCHIVE_BYTES, + false ) } @@ -192,7 +198,8 @@ async function persistLargeValue( json: string, size: number, context: LargeValueStoreContext, - limitBytes: number + limitBytes: number, + cacheInProcess = true ): Promise { assertDurableLargeValueSize(size, limitBytes) const referencedKeys = collectLargeValueKeys(value) @@ -213,7 +220,8 @@ async function persistLargeValue( key = undefined } } - const cached = cacheLargeValue(id, value, size, context, { recoverable: Boolean(key) }) + const cached = + cacheInProcess && cacheLargeValue(id, value, size, context, { recoverable: Boolean(key) }) if (!key && !cached) { throw new Error('Cannot retain large execution value without durable storage') } diff --git a/apps/sim/lib/logs/execution/json-byte-size.test.ts b/apps/sim/lib/logs/execution/json-byte-size.test.ts new file mode 100644 index 00000000000..b696328ae94 --- /dev/null +++ b/apps/sim/lib/logs/execution/json-byte-size.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { getJsonByteSize } from '@/lib/logs/execution/json-byte-size' + +const LIMIT = 10 * 1024 * 1024 + +function jsonBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8') +} + +describe('getJsonByteSize', () => { + it('counts a shared subtree once per occurrence, like JSON.stringify', () => { + const rows = Array.from({ length: 50 }, (_, i) => ({ id: i, name: `row-${i}` })) + const output = { rows } + const executionData = { + traceSpans: [{ output }, { output: { results: [output, output] } }], + } + expect(getJsonByteSize(executionData, LIMIT)).toBe(jsonBytes(executionData)) + }) + + it('lets a payload whose shared subtrees exceed the limit trip it', () => { + const big = { text: 'x'.repeat(1000) } + const payload = Array.from({ length: 10 }, () => big) + expect(getJsonByteSize(payload, 5000)).toBe(5001) + }) + + it.each([ + ['Dates through toJSON', { at: new Date(0), nested: [{ at: new Date(1) }] }], + ['a toJSON that omits its member', { gone: { toJSON: () => undefined }, kept: 1 }], + ['holes in a sparse array', { list: [1, undefined, 3, undefined, undefined] }], + ['an omitted member before the first written one', { skipped: undefined, kept: 1 }], + ['boxed primitives', { n: new Number(12345), s: new String('boxed'), b: new Boolean(false) }], + [ + 'a boxed boolean whose valueOf is overridden', + { + b: Object.assign(new Boolean(true), { + valueOf: () => { + throw new Error('JSON.stringify reads the internal value, not valueOf') + }, + }), + }, + ], + ['a function with toJSON', { fn: Object.assign(() => 1, { toJSON: () => 'serialized' }) }], + ['escapes, multi-byte text, and lone surrogates', { 'k"\\': 'a\n\u0001é漢😀\ud800' }], + ])('matches JSON.stringify for %s', (_name, payload) => { + expect(getJsonByteSize(payload, LIMIT)).toBe(jsonBytes(payload)) + }) + + it('measures nesting deeper than a recursive walk could reach', () => { + const depth = 50_000 + let nested: Record = {} + for (let level = 0; level < depth; level++) nested = { c: nested } + expect(getJsonByteSize(nested, LIMIT)).toBe(depth * '{"c":}'.length + '{}'.length) + }) + + it('still measures a cycle, so oversized cyclic data stays eligible for compaction', () => { + const node: Record = { a: 1 } + node.self = node + expect(getJsonByteSize(node, LIMIT)).toBe(jsonBytes({ a: 1 }) + ',"self":'.length) + }) +}) diff --git a/apps/sim/lib/logs/execution/json-byte-size.ts b/apps/sim/lib/logs/execution/json-byte-size.ts new file mode 100644 index 00000000000..f7d3f866fc0 --- /dev/null +++ b/apps/sim/lib/logs/execution/json-byte-size.ts @@ -0,0 +1,121 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { quotedStringBytes } from '@/lib/core/utils/bounded-json' + +/** + * Byte length of `JSON.stringify(value)`, measured without building the string. + * Stops early and returns `maxBytes + 1` once the count passes `maxBytes`. A + * cycle or BigInt, which `JSON.stringify` rejects, is still measured (the cycle + * edge as absent, the BigInt as its string) so oversized data stays eligible + * for compaction; callers treat `undefined` as fitting. Returns `undefined` only + * if a `toJSON` throws. + */ +export function getJsonByteSize(value: unknown, maxBytes: number): number | undefined { + // Ancestors only: JSON.stringify writes a shared subtree once per occurrence, + // so only a true cycle may be skipped. + const ancestors = new WeakSet() + let bytes = 0 + + const add = (amount: number) => { + bytes += amount + if (bytes > maxBytes) { + throw new Error('json_size_limit_reached') + } + } + + const addString = (value: string) => { + add(quotedStringBytes(value, maxBytes - bytes) ?? maxBytes - bytes + 1) + } + + /** Applies `toJSON`, then unboxes primitive wrappers, as `JSON.stringify` does. */ + const resolve = (raw: unknown, key: string): unknown => { + const toJSON = + (typeof raw === 'object' && raw !== null) || + typeof raw === 'function' || + typeof raw === 'bigint' + ? (raw as { toJSON?: unknown }).toJSON + : undefined + const value = typeof toJSON === 'function' ? toJSON.call(raw, key) : raw + if (value instanceof Number) return Number(value) + if (value instanceof String) return String(value) + if (value instanceof Boolean) return Boolean.prototype.valueOf.call(value) + if (value instanceof BigInt) return BigInt.prototype.valueOf.call(value) + return value + } + + const isOmitted = (item: unknown): boolean => + item === undefined || typeof item === 'function' || typeof item === 'symbol' + + /** A container whose members are still being measured. Iterative, so nesting depth cannot overflow the stack. */ + type Frame = { + node: object + keys: string[] | undefined + length: number + index: number + written: number + } + const stack: Frame[] = [] + + /** Measures a resolved value; a container's members are measured as the loop below reaches them. */ + const enter = (item: unknown): void => { + if (item === null || isOmitted(item)) { + add(4) + return + } + if (typeof item === 'string') { + addString(item) + return + } + if (typeof item === 'bigint') { + addString(item.toString()) + return + } + if (typeof item === 'number' || typeof item === 'boolean') { + add(Buffer.byteLength(JSON.stringify(item) ?? 'null', 'utf8')) + return + } + if (typeof item !== 'object' || ancestors.has(item)) { + return + } + ancestors.add(item) + add(2) + const keys = Array.isArray(item) ? undefined : Object.keys(item) + stack.push({ + node: item, + keys, + length: keys ? keys.length : (item as unknown[]).length, + index: 0, + written: 0, + }) + } + + try { + enter(resolve(value, '')) + while (stack.length > 0) { + const frame = stack[stack.length - 1] + if (frame.index >= frame.length) { + ancestors.delete(frame.node) + stack.pop() + continue + } + const index = frame.index++ + if (!frame.keys) { + if (index > 0) add(1) + enter(resolve((frame.node as unknown[])[index], String(index))) + continue + } + const key = frame.keys[index] + const entry = resolve((frame.node as Record)[key], key) + if (isOmitted(entry)) continue + if (frame.written++ > 0) add(1) + addString(key) + add(1) + enter(entry) + } + return bytes + } catch (error) { + if (getErrorMessage(error) === 'json_size_limit_reached') { + return maxBytes + 1 + } + return undefined + } +} diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 43a57faa7f1..3ce60b29d77 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -44,6 +44,7 @@ import { collectLargeValueReferenceKeys, replaceLargeValueReferenceKeysWithClient, } from '@/lib/execution/payloads/large-value-metadata' +import { getJsonByteSize } from '@/lib/logs/execution/json-byte-size' import { redactLargeValueRefs } from '@/lib/logs/execution/pii-large-values' import { type RedactablePayload, redactPIIFromExecution } from '@/lib/logs/execution/pii-redaction' import { @@ -79,6 +80,7 @@ import type { WorkflowState, } from '@/lib/logs/types' import { emitExecutionCompletedEvent } from '@/lib/workspace-events/emitter' +import { isLiveExecutionState } from '@/executor/execution/snapshot-serializer' import type { SerializableExecutionState } from '@/executor/execution/types' const logger = createLogger('ExecutionLogger') @@ -90,6 +92,7 @@ const logger = createLogger('ExecutionLogger') */ const execDb = dbFor('exec') const MAX_EXECUTION_DATA_BYTES = 3 * 1024 * 1024 +const EXECUTION_DATA_SIZE_PROBE_LIMIT = MAX_EXECUTION_DATA_BYTES + 1 const MAX_TRACE_IO_BYTES = 8 * 1024 const MAX_WORKFLOW_VALUE_BYTES = 512 * 1024 const EXECUTION_LOG_STATEMENT_TIMEOUT_MS = 30_000 @@ -137,80 +140,6 @@ type UsageThresholdEmailContext = orgUsageBefore: number } -function getJsonByteSize( - value: unknown, - maxBytes = MAX_EXECUTION_DATA_BYTES + 1 -): number | undefined { - const seen = new WeakSet() - let bytes = 0 - - const add = (amount: number) => { - bytes += amount - if (bytes > maxBytes) { - throw new Error('json_size_limit_reached') - } - } - - const visit = (item: unknown): void => { - if (item === undefined || typeof item === 'function' || typeof item === 'symbol') { - add(4) - return - } - if (item === null) { - add(4) - return - } - if (typeof item === 'string') { - add(Buffer.byteLength(JSON.stringify(item), 'utf8')) - return - } - if (typeof item === 'bigint') { - add(Buffer.byteLength(JSON.stringify(item.toString()), 'utf8')) - return - } - if (typeof item === 'number' || typeof item === 'boolean') { - add(Buffer.byteLength(JSON.stringify(item) ?? 'null', 'utf8')) - return - } - if (typeof item !== 'object') { - add(4) - return - } - if (seen.has(item)) { - return - } - seen.add(item) - - if (Array.isArray(item)) { - add(2) - item.forEach((entry, index) => { - if (index > 0) add(1) - visit(entry) - }) - return - } - - const entries = Object.entries(item) - add(2) - entries.forEach(([key, entry], index) => { - if (entry === undefined || typeof entry === 'function' || typeof entry === 'symbol') return - if (index > 0) add(1) - add(Buffer.byteLength(JSON.stringify(key), 'utf8') + 1) - visit(entry) - }) - } - - try { - visit(value) - return bytes - } catch (error) { - if (getErrorMessage(error) === 'json_size_limit_reached') { - return maxBytes + 1 - } - return undefined - } -} - function describeValue(value: unknown): string { if (value === null) return 'null' if (value === undefined) return 'undefined' @@ -347,13 +276,13 @@ function recordStoredByteSize(executionData: ExecutionData): { executionData: ExecutionData storedBytes?: number } { - const firstBytes = getJsonByteSize(executionData) + const firstBytes = getJsonByteSize(executionData, EXECUTION_DATA_SIZE_PROBE_LIMIT) if (firstBytes === undefined) { return { executionData } } const withFirstSize = { ...executionData, executionDataStoredBytes: firstBytes } - const secondBytes = getJsonByteSize(withFirstSize) + const secondBytes = getJsonByteSize(withFirstSize, EXECUTION_DATA_SIZE_PROBE_LIMIT) if (secondBytes === undefined || secondBytes === firstBytes) { return { executionData: withFirstSize, storedBytes: secondBytes ?? firstBytes } } @@ -361,7 +290,7 @@ function recordStoredByteSize(executionData: ExecutionData): { const withSecondSize = { ...executionData, executionDataStoredBytes: secondBytes } return { executionData: withSecondSize, - storedBytes: getJsonByteSize(withSecondSize) ?? secondBytes, + storedBytes: getJsonByteSize(withSecondSize, EXECUTION_DATA_SIZE_PROBE_LIMIT) ?? secondBytes, } } @@ -415,7 +344,7 @@ export class ExecutionLogger { executionData: ExecutionData, executionId: string ): ExecutionData { - const originalBytes = getJsonByteSize(executionData) + const originalBytes = getJsonByteSize(executionData, EXECUTION_DATA_SIZE_PROBE_LIMIT) if (originalBytes === undefined || originalBytes <= MAX_EXECUTION_DATA_BYTES) { return executionData } @@ -777,7 +706,17 @@ export class ExecutionLogger { // the log's large values must get the logs policy applied like inline content // does. Masking is idempotent, so already-masked spans are unaffected; a ref // that can't be materialized/re-stored falls back to a marker. - const working = await redactLargeValueRefs(payload, { + // A completed run hands over live execution state rather than a JSON clone; + // normalize it (Dates to strings, undefined dropped) so masking sees the + // same shapes the persisted state will have. Other states are JSON already. + const normalizedPayload = !isLiveExecutionState(payload.executionState) + ? payload + : { + ...payload, + // utils-lint-allow: JSON normalization of the state, not a deep clone + executionState: JSON.parse(JSON.stringify(payload.executionState)), + } + const working = await redactLargeValueRefs(normalizedPayload, { entityTypes: config.entityTypes, language: config.language, customPatterns: config.customPatterns, diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index 213006827a6..9e607584c7c 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -32,6 +32,12 @@ function setFilteredValue(output: Record, key: string, value: u /** * Recursively filters hidden keys from nested objects for cleaner display. * Used by both executor (for log output) and UI (for display). + * + * Copy-on-write: a plain object or array whose subtree needs no change is + * returned as-is, so a block log shares structure with the block's compacted + * state output instead of holding a second copy for the rest of the run. A + * copy starts only at the first changed child; non-plain prototypes (Date, + * class instances, null-prototype objects) are always rebuilt. */ export function filterHiddenOutputKeys(value: unknown): unknown { if (value === null || value === undefined) { @@ -39,18 +45,39 @@ export function filterHiddenOutputKeys(value: unknown): unknown { } if (Array.isArray(value)) { - return value.map((item) => filterHiddenOutputKeys(item)) + if (Object.getPrototypeOf(value) !== Array.prototype) { + return value.map((item) => filterHiddenOutputKeys(item)) + } + let mapped: unknown[] | undefined + for (let index = 0; index < value.length; index++) { + if (!(index in value)) continue + const item = value[index] + const filteredItem = filterHiddenOutputKeys(item) + if (!mapped && filteredItem !== item) mapped = value.slice(0, index) + if (mapped) mapped[index] = filteredItem + } + if (!mapped) return value + mapped.length = value.length + return mapped } if (typeof value === 'object') { - const filtered: Record = {} - for (const [key, val] of Object.entries(value as Record)) { - if (HIDDEN_OUTPUT_KEYS.has(key)) { - continue + const entries = Object.entries(value as Record) + let filtered: Record | undefined = + Object.getPrototypeOf(value) === Object.prototype ? undefined : {} + for (let index = 0; index < entries.length; index++) { + const [key, val] = entries[index] + const hidden = HIDDEN_OUTPUT_KEYS.has(key) + const filteredVal = hidden ? undefined : filterHiddenOutputKeys(val) + if (!filtered && (hidden || filteredVal !== val)) { + filtered = {} + for (const [previousKey, previousVal] of entries.slice(0, index)) { + setFilteredValue(filtered, previousKey, previousVal) + } } - setFilteredValue(filtered, key, filterHiddenOutputKeys(val)) + if (filtered && !hidden) setFilteredValue(filtered, key, filteredVal) } - return filtered + return filtered ?? value } return value From c723046291cad9aa0e60a5cdc8e70a402d22ee30 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 10:03:20 -0700 Subject: [PATCH 07/68] fix(logs): keep a block log's file size from changing after the block completes (#8626) --- apps/sim/executor/utils/output-filter.test.ts | 11 +++++++++++ .../sim/lib/logs/execution/trace-spans/trace-spans.ts | 7 +++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/sim/executor/utils/output-filter.test.ts b/apps/sim/executor/utils/output-filter.test.ts index 9d260c5f076..be5317b73ef 100644 --- a/apps/sim/executor/utils/output-filter.test.ts +++ b/apps/sim/executor/utils/output-filter.test.ts @@ -46,4 +46,15 @@ describe('output filtering', () => { expect(output.nested).not.toHaveProperty('childTraceSpans') expect((output.nested as typeof nestedSpans).kept).toBe(nestedSpans.kept) }) + + it('keeps the file size a block completed with when hydration later updates it in place', () => { + const file = { id: 'f1', key: 'k1', url: 'u', name: 'deck.pptx', size: 10, type: 'pptx' } + const blockOutput = { file, rows: [{ id: 1 }] } + + const output = filterOutputForLog('function', blockOutput as never) + file.size = 4096 + + expect((output.file as typeof file).size).toBe(10) + expect(output.rows).toBe(blockOutput.rows) + }) }) diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index 9e607584c7c..f1674a33e93 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -1,3 +1,4 @@ +import { isUserFile } from '@/lib/core/utils/user-file' import { groupIterationBlocks } from '@/lib/logs/execution/trace-spans/iteration-grouping' import { createSpanFromLog } from '@/lib/logs/execution/trace-spans/span-factory' import type { TraceSpan } from '@/lib/logs/types' @@ -37,7 +38,7 @@ function setFilteredValue(output: Record, key: string, value: u * returned as-is, so a block log shares structure with the block's compacted * state output instead of holding a second copy for the rest of the run. A * copy starts only at the first changed child; non-plain prototypes (Date, - * class instances, null-prototype objects) are always rebuilt. + * class instances, null-prototype objects) and file objects are always rebuilt. */ export function filterHiddenOutputKeys(value: unknown): unknown { if (value === null || value === undefined) { @@ -63,8 +64,10 @@ export function filterHiddenOutputKeys(value: unknown): unknown { if (typeof value === 'object') { const entries = Object.entries(value as Record) + // File objects are always copied: file hydration later updates `size` in + // place on the shared state object, and a log keeps the value it completed with. let filtered: Record | undefined = - Object.getPrototypeOf(value) === Object.prototype ? undefined : {} + Object.getPrototypeOf(value) === Object.prototype && !isUserFile(value) ? undefined : {} for (let index = 0; index < entries.length; index++) { const [key, val] = entries[index] const hidden = HIDDEN_OUTPUT_KEYS.has(key) From ea88578011cf1bb1d701966eed04cf9949df1e60 Mon Sep 17 00:00:00 2001 From: mzxchandra <129460234+mzxchandra@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:33:00 -0700 Subject: [PATCH 08/68] feat(projects): add project identity and lifecycle foundation (#8580) * feat(projects): add project identity and lifecycle foundation * feat(projects): create projects with their initial environment * docs(projects): record project files follow-up * docs(projects): explain project and workspace creation flows * fix(projects): stage activation after compatible writers deploy * refactor(projects): prepare compatible writers for the SQL backfill * fix(projects): clean up automatically created fixture Projects * fix(workflows): guard restore against concurrent workspace archive * fix(projects): close lifecycle races and surface rollout conflicts * fix(workflows): return not found when import loses archive race --- apps/docs/openapi-v2-resources.json | 66 +- .../app/api/folders/[id]/duplicate/route.ts | 4 +- apps/sim/app/api/projects/[id]/route.ts | 45 + .../by-workspace/[workspaceId]/route.ts | 18 + apps/sim/app/api/projects/route.ts | 28 + .../api/superuser/import-workflow/route.ts | 28 +- .../api/v1/admin/workflows/import/route.ts | 26 +- .../v1/admin/workspaces/[id]/import/route.ts | 22 +- apps/sim/app/api/workflows/route.test.ts | 3 + apps/sim/app/api/workspaces/[id]/route.ts | 7 + .../components/group-detail.tsx | 11 + .../components/project-issue-restrictions.tsx | 63 + .../lib/copy/copy-workflows.test.ts | 26 +- .../lib/copy/copy-workflows.ts | 2 + .../workspace-forking/lib/create-fork.test.ts | 16 +- .../ee/workspace-forking/lib/create-fork.ts | 15 +- .../lib/lineage/unlink.test.ts | 22 +- .../workspace-forking/lib/lineage/unlink.ts | 12 +- apps/sim/hooks/queries/projects.ts | 28 + apps/sim/lib/api/contracts/projects.ts | 101 + apps/sim/lib/api/contracts/user.ts | 1 + .../billing/organizations/lock-order.test.ts | 1 + .../lib/billing/organizations/membership.ts | 24 +- apps/sim/lib/core/config/env.ts | 1 + .../search-source-setup.integration.ts | 2 + .../sim/lib/permission-groups/capabilities.ts | 9 + apps/sim/lib/permission-groups/fields.test.ts | 2 + apps/sim/lib/permission-groups/fields.ts | 5 + .../lib/permission-groups/group-manager.ts | 36 +- apps/sim/lib/projects/README.md | 78 + .../__integration__/foundation.integration.ts | 1309 + apps/sim/lib/projects/account-deletion.ts | 155 + .../lib/projects/application/authorization.ts | 128 + .../projects/application/create-project.ts | 136 + apps/sim/lib/projects/application/index.ts | 10 + .../lib/projects/application/operations.ts | 57 + .../sim/lib/projects/application/use-cases.ts | 211 + apps/sim/lib/projects/create-input.ts | 8 + apps/sim/lib/projects/lifecycle.ts | 66 + apps/sim/lib/projects/membership.ts | 296 + apps/sim/lib/projects/rollout.server.ts | 13 + apps/sim/lib/users/account-deletion.ts | 15 +- apps/sim/lib/workflows/lifecycle.ts | 220 +- .../orchestration/workflow-lifecycle.test.ts | 5 + .../orchestration/workflow-lifecycle.ts | 7 +- .../workflows/persistence/duplicate.test.ts | 3 + .../lib/workflows/persistence/duplicate.ts | 6 +- .../workflows/persistence/new-workflow-row.ts | 8 +- .../__integration__/fork-sync.integration.ts | 24 + apps/sim/lib/workspaces/active-workspace.ts | 19 + apps/sim/lib/workspaces/admin-move.ts | 8 + apps/sim/lib/workspaces/create.test.ts | 3 + apps/sim/lib/workspaces/create.ts | 55 +- apps/sim/lib/workspaces/lifecycle.test.ts | 99 - apps/sim/lib/workspaces/lifecycle.ts | 242 +- .../organization-workspaces.integration.ts | 1 + .../lib/workspaces/organization-workspaces.ts | 3 + knip.jsonc | 2 + packages/audit/src/types.ts | 4 + .../db/migrations/0394_project_foundation.sql | 25 + .../db/migrations/meta/0394_snapshot.json | 29944 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 49 + packages/sim-cli/src/generated/v2-api.ts | 9 + .../src/mocks/schema-tables.generated.ts | 2 + scripts/test-integration.ts | 12 +- 66 files changed, 33464 insertions(+), 399 deletions(-) create mode 100644 apps/sim/app/api/projects/[id]/route.ts create mode 100644 apps/sim/app/api/projects/by-workspace/[workspaceId]/route.ts create mode 100644 apps/sim/app/api/projects/route.ts create mode 100644 apps/sim/ee/access-control/components/project-issue-restrictions.tsx create mode 100644 apps/sim/hooks/queries/projects.ts create mode 100644 apps/sim/lib/api/contracts/projects.ts create mode 100644 apps/sim/lib/projects/README.md create mode 100644 apps/sim/lib/projects/__integration__/foundation.integration.ts create mode 100644 apps/sim/lib/projects/account-deletion.ts create mode 100644 apps/sim/lib/projects/application/authorization.ts create mode 100644 apps/sim/lib/projects/application/create-project.ts create mode 100644 apps/sim/lib/projects/application/index.ts create mode 100644 apps/sim/lib/projects/application/operations.ts create mode 100644 apps/sim/lib/projects/application/use-cases.ts create mode 100644 apps/sim/lib/projects/create-input.ts create mode 100644 apps/sim/lib/projects/lifecycle.ts create mode 100644 apps/sim/lib/projects/membership.ts create mode 100644 apps/sim/lib/projects/rollout.server.ts create mode 100644 apps/sim/lib/workspaces/active-workspace.ts delete mode 100644 apps/sim/lib/workspaces/lifecycle.test.ts create mode 100644 packages/db/migrations/0394_project_foundation.sql create mode 100644 packages/db/migrations/meta/0394_snapshot.json diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 7a7f2abfe73..2426b9b5ce9 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -16016,6 +16016,16 @@ "disableKnowledgeBaseExport": { "type": "boolean", "description": "Prevent downloading a whole knowledge base as an archive." + }, + "deniedPartialAccessProjectIssues": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": "Issues in the listed Projects are unavailable to teammates without access to every active environment." } }, "required": [ @@ -16060,7 +16070,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "deniedPartialAccessProjectIssues" ], "additionalProperties": false, "description": "Resolved restrictions. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none." @@ -16190,7 +16201,8 @@ "disableToolAutoApproval": false, "hideSandboxesTab": false, "disableOAuthAppAccess": false, - "disableKnowledgeBaseExport": false + "disableKnowledgeBaseExport": false, + "deniedPartialAccessProjectIssues": [] }, "isDefault": false, "membershipMode": "inherit", @@ -16265,7 +16277,8 @@ "disableToolAutoApproval": false, "hideSandboxesTab": false, "disableOAuthAppAccess": false, - "disableKnowledgeBaseExport": false + "disableKnowledgeBaseExport": false, + "deniedPartialAccessProjectIssues": [] }, "isDefault": false, "membershipMode": "inherit", @@ -16519,6 +16532,15 @@ "disableKnowledgeBaseExport": { "type": "boolean", "description": "Prevent downloading a whole knowledge base as an archive." + }, + "deniedPartialAccessProjectIssues": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": "Issues in the listed Projects are unavailable to teammates without access to every active environment." } }, "additionalProperties": false, @@ -16610,7 +16632,8 @@ "disableToolAutoApproval": false, "hideSandboxesTab": false, "disableOAuthAppAccess": false, - "disableKnowledgeBaseExport": false + "disableKnowledgeBaseExport": false, + "deniedPartialAccessProjectIssues": [] }, "isDefault": false, "membershipMode": "inherit", @@ -16683,7 +16706,8 @@ "disableToolAutoApproval": false, "hideSandboxesTab": false, "disableOAuthAppAccess": false, - "disableKnowledgeBaseExport": false + "disableKnowledgeBaseExport": false, + "deniedPartialAccessProjectIssues": [] }, "isDefault": false, "membershipMode": "inherit", @@ -16944,6 +16968,15 @@ "disableKnowledgeBaseExport": { "type": "boolean", "description": "Prevent downloading a whole knowledge base as an archive." + }, + "deniedPartialAccessProjectIssues": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": "Issues in the listed Projects are unavailable to teammates without access to every active environment." } }, "additionalProperties": false, @@ -18220,6 +18253,16 @@ "disableKnowledgeBaseExport": { "type": "boolean", "description": "Prevent downloading a whole knowledge base as an archive." + }, + "deniedPartialAccessProjectIssues": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": "Issues in the listed Projects are unavailable to teammates without access to every active environment." } }, "required": [ @@ -18264,7 +18307,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "deniedPartialAccessProjectIssues" ], "additionalProperties": false }, @@ -20749,7 +20793,7 @@ "description": "Access request being reviewed." }, "changes": { - "maxItems": 42, + "maxItems": 43, "type": "array", "items": { "type": "object", @@ -20798,7 +20842,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "deniedPartialAccessProjectIssues" ], "description": "Permission restriction changed by approval." }, @@ -21310,7 +21355,7 @@ "description": "Access request being reviewed." }, "changes": { - "maxItems": 42, + "maxItems": 43, "type": "array", "items": { "type": "object", @@ -21359,7 +21404,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "deniedPartialAccessProjectIssues" ], "description": "Permission restriction changed by approval." }, diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index cd0e6fea92c..84d529d2345 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -13,7 +13,7 @@ import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { DbOrTx } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { deduplicateFolderName } from '@/lib/folders/naming' import { nextFolderSortOrder } from '@/lib/folders/orchestration' import { assertFolderCollectionHasRoom, toFolderApi } from '@/lib/folders/queries' @@ -409,7 +409,7 @@ async function duplicateFolderStructure( } async function duplicateWorkflowsInFolderTree( - tx: DbOrTx, + tx: DbTransaction, sourceWorkspaceId: string, targetWorkspaceId: string, folderMapping: Map, diff --git a/apps/sim/app/api/projects/[id]/route.ts b/apps/sim/app/api/projects/[id]/route.ts new file mode 100644 index 00000000000..d8e454db122 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/route.ts @@ -0,0 +1,45 @@ +import { + archiveProjectContract, + getProjectContract, + renameProjectContract, +} from '@/lib/api/contracts/projects' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + archiveProject, + getProject, + projectOperations, + renameProject, +} from '@/lib/projects/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectContract, + auth: internalSessionAuth, + operation: projectOperations.get, + rateLimit: internalRateLimits.user({ bucketName: 'projects.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ projectId: params.id, ...query }), + useCase: getProject, +}) +export const PATCH = defineInternalJsonRoute({ + contract: renameProjectContract, + auth: internalSessionAuth, + operation: projectOperations.rename, + rateLimit: internalRateLimits.user({ bucketName: 'projects.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, name: body.name }), + useCase: renameProject, +}) +export const DELETE = defineInternalJsonRoute({ + contract: archiveProjectContract, + auth: internalSessionAuth, + operation: projectOperations.archive, + rateLimit: internalRateLimits.user({ bucketName: 'projects.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id }), + useCase: archiveProject, +}) diff --git a/apps/sim/app/api/projects/by-workspace/[workspaceId]/route.ts b/apps/sim/app/api/projects/by-workspace/[workspaceId]/route.ts new file mode 100644 index 00000000000..d04b76ed361 --- /dev/null +++ b/apps/sim/app/api/projects/by-workspace/[workspaceId]/route.ts @@ -0,0 +1,18 @@ +import { getWorkspaceProjectContract } from '@/lib/api/contracts/projects' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getWorkspaceProject, projectOperations } from '@/lib/projects/application' + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceProjectContract, + auth: internalSessionAuth, + operation: projectOperations.get, + rateLimit: internalRateLimits.user({ bucketName: 'projects.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getWorkspaceProject, +}) diff --git a/apps/sim/app/api/projects/route.ts b/apps/sim/app/api/projects/route.ts new file mode 100644 index 00000000000..852cc6f3a4c --- /dev/null +++ b/apps/sim/app/api/projects/route.ts @@ -0,0 +1,28 @@ +import { createProjectContract, listProjectsContract } from '@/lib/api/contracts/projects' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { createProject, listProjects, projectOperations } from '@/lib/projects/application' + +export const GET = defineInternalJsonRoute({ + contract: listProjectsContract, + auth: internalSessionAuth, + operation: projectOperations.list, + rateLimit: internalRateLimits.user({ bucketName: 'projects.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listProjects, +}) + +export const POST = defineInternalJsonRoute({ + contract: createProjectContract, + auth: internalSessionAuth, + operation: projectOperations.create, + rateLimit: internalRateLimits.user({ bucketName: 'projects.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: createProject, +}) diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index cc59d7627bf..6caaa1029fc 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { importWorkflowAsSuperuserContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' @@ -136,17 +137,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { null ) - await db.insert(workflow).values( - await buildNewWorkflowRow(db, { - id: newWorkflowId, - userId: session.user.id, - workspaceId: targetWorkspaceId, - folderId: null, - name: dedupedName, - description: sourceWorkflow.description, - variables: sourceWorkflow.variables || {}, - }) - ) + await db.transaction(async (tx) => { + await tx.insert(workflow).values( + await buildNewWorkflowRow(tx, { + id: newWorkflowId, + userId: session.user.id, + workspaceId: targetWorkspaceId, + folderId: null, + name: dedupedName, + description: sourceWorkflow.description, + variables: sourceWorkflow.variables || {}, + }) + ) + }) // Save using existing persistence logic const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData, { @@ -225,6 +228,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { copilotChatsImported, }) } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 }) + } logger.error('Error importing workflow', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 0c7f0f3c405..12a02a6c855 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -28,6 +28,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' @@ -115,16 +116,18 @@ export const POST = withRouteHandler( const workflowId = generateId() const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null) - await db.insert(workflow).values( - await buildNewWorkflowRow(db, { - id: workflowId, - userId: workspaceData.ownerId, - workspaceId, - folderId: folderId || null, - name: dedupedName, - description: workflowDescription, - }) - ) + await db.transaction(async (tx) => { + await tx.insert(workflow).values( + await buildNewWorkflowRow(tx, { + id: workflowId, + userId: workspaceData.ownerId, + workspaceId, + folderId: folderId || null, + name: dedupedName, + description: workflowDescription, + }) + ) + }) /** * Same normalization the editor and the v1 import API run, via the one @@ -180,6 +183,9 @@ export const POST = withRouteHandler( return NextResponse.json(response) } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return notFoundResponse('Workspace') + } if (error instanceof FolderNotFoundError) { return badRequestResponse(error.message) } diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index f19a1c072a7..44ee2523b7b 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -350,16 +350,18 @@ async function importSingleWorkflow( const workflowId = generateId() const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId) - await db.insert(workflow).values( - await buildNewWorkflowRow(db, { - id: workflowId, - userId: ownerId, - workspaceId, - folderId: targetFolderId, - name: dedupedName, - description: workflowData.metadata?.description || 'Imported via Admin API', - }) - ) + await db.transaction(async (tx) => { + await tx.insert(workflow).values( + await buildNewWorkflowRow(tx, { + id: workflowId, + userId: ownerId, + workspaceId, + folderId: targetFolderId, + name: dedupedName, + description: workflowData.metadata?.description || 'Imported via Admin API', + }) + ) + }) /** * Same normalization the editor, the v1 import API and the single-workflow diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 8e3e44b65b5..53ddf9390ee 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -45,6 +45,9 @@ describe('Workflows API Route - POST ordering', () => { beforeEach(() => { resetDbChainMock() + queueTableRows(schemaMock.workspace, [ + { archivedAt: null, forkSyncNewWorkflowsExcluded: false }, + ]) vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('workflow-new-id'), diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index c58003d5fd2..afc662ad4b7 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -7,6 +7,7 @@ import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/co import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' +import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { archiveWorkspace } from '@/lib/workspaces/lifecycle' @@ -316,6 +317,12 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof OrchestrationError) { + return NextResponse.json( + { error: error.message }, + { status: statusForOrchestrationError(error.code) } + ) + } logger.error(`Error deleting workspace ${workspaceId}:`, error) return NextResponse.json({ error: 'Failed to delete workspace' }, { status: 500 }) } diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 16e819069bd..166c83e3284 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -61,6 +61,7 @@ import { getAllBlocks } from '@/blocks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import type { BlockConfig } from '@/blocks/types' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { ProjectIssueRestrictions } from '@/ee/access-control/components/project-issue-restrictions' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { type PermissionGroup, @@ -1783,6 +1784,16 @@ export function GroupDetail({ {configTab === 'platform' && (
+ + setEditingConfig((previous) => ({ + ...previous, + deniedPartialAccessProjectIssues: value, + })) + } + />
void +} + +/** Project choices use the authorized inventory; policy remains enforced at the application boundary. */ +export function ProjectIssueRestrictions({ + organizationId, + value, + onChange, +}: ProjectIssueRestrictionsProps) { + const projects = useProjects(organizationId) + if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) { + return null + } + const selected = new Set(value) + return ( +
+

Restrict Issues for partial-access teammates

+

+ For selected Projects, teammates governed by this group need access to every active + environment to use Issues. +

+ {projects.error && ( +

{projects.error.message}

+ )} + {projects.data?.pages + .flatMap((page) => page.projects) + .map((project) => ( + + ))} + {projects.hasNextPage && ( + void projects.fetchNextPage()}> + Load more + + )} +
+ ) +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index 00052299e3e..8c118090240 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -1,9 +1,11 @@ +import { workspace } from '@sim/db/schema' import { createBlock } from '@sim/testing/factories' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' import { workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, } from '@sim/testing/mocks/workflows-persistence-utils.mock' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { FolderCollectionFullError } from '@/lib/folders/errors' @@ -23,6 +25,11 @@ import { const mockSaveWorkflowToNormalizedTables = workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables +beforeEach(() => { + resetDbChainMock() + queueTableRows(workspace, [{ archivedAt: null, forkSyncNewWorkflowsExcluded: false }]) +}) + describe('buildWorkflowNameRegistry', () => { it('excludes the workflow itself so a replace can keep its own name', () => { const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }]) @@ -294,7 +301,7 @@ describe('copyWorkflowStateIntoTarget source tool identities', () => { async () => { mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) await copyWorkflowStateIntoTarget({ - tx: { insert: () => ({ values: () => Promise.resolve() }) } as unknown as DbOrTx, + tx: dbChainMock.db as unknown as DbOrTx, targetWorkflowId: 'wf-child', targetWorkspaceId: 'ws-child', userId: 'user', @@ -358,7 +365,7 @@ describe('copied MCP configuration normalization', () => { }, }) await copyWorkflowStateIntoTarget({ - tx: { insert: () => ({ values: () => Promise.resolve() }) } as unknown as DbOrTx, + tx: dbChainMock.db as unknown as DbOrTx, targetWorkflowId: 'wf-child', targetWorkspaceId: 'ws-target', userId: 'target-user', @@ -396,9 +403,7 @@ describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => async () => { mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) const seenCanonicalModes: Array | undefined> = [] - const tx = { - insert: () => ({ values: () => Promise.resolve() }), - } as unknown as DbOrTx + const tx = dbChainMock.db as unknown as DbOrTx await copyWorkflowStateIntoTarget({ tx, @@ -484,11 +489,7 @@ describe('copyWorkflowStateIntoTarget webhook path pinning', () => { resolveBlockId: (_targetWorkflowId: string, sourceBlockId: string) => `tgt-${sourceBlockId}`, } - /** `replace` mode updates the existing target workflow row; stub just that chain. */ - const stubTx = () => - ({ - update: () => ({ set: () => ({ where: () => Promise.resolve() }) }), - }) as unknown as DbOrTx + const stubTx = () => dbChainMock.db as unknown as DbOrTx function writtenSubBlocks() { const state = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)?.[1] as { @@ -549,8 +550,7 @@ describe('copyWorkflowStateIntoTarget custom-block remap', () => { resolveBlockId: (_t: string, sourceBlockId: string) => `tgt-${sourceBlockId}`, } - const stubTx = () => - ({ update: () => ({ set: () => ({ where: () => Promise.resolve() }) }) }) as unknown as DbOrTx + const stubTx = () => dbChainMock.db as unknown as DbOrTx function writtenBlock() { const state = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)?.[1] as { diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 847f2ec2984..21c9c21b83d 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -26,6 +26,7 @@ import { type SubBlockTransform, } from '@/lib/workflows/references/remap-references' import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import { lockActiveWorkspace } from '@/lib/workspaces/active-workspace' import { deriveForkBlockId, type ForkBlockIdResolver, @@ -502,6 +503,7 @@ export async function copyWorkflowStateIntoTarget( requestId = 'unknown', } = params + await lockActiveWorkspace(tx, targetWorkspaceId) const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null const varIdMapping = new Map() diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index be6e8e5738c..f8b33a85939 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -1,4 +1,4 @@ -import { workspace } from '@sim/db/schema' +import { projectWorkspace, workspace } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { workflowsPersistenceUtilsMock } from '@sim/testing/mocks/workflows-persistence-utils.mock' import { @@ -140,12 +140,17 @@ function forkParams(selection?: { describe('createFork storage headroom gate', () => { beforeEach(() => { resetDbChainMock() + queueTableRows(projectWorkspace, [{ projectId: 'project-source' }]) + queueTableRows(projectWorkspace, [ + { project: { id: 'project-source', organizationId: null, archivedAt: null } }, + ]) /** * The fork transaction re-reads the parent's organization under the lock to * confirm it has not moved since `assertCanFork` captured the policy. * Matches POLICY.organizationId, so the fork proceeds. */ queueTableRows(workspace, [{ organizationId: null }]) + queueTableRows(workspace, [{ archivedAt: null, forkSyncNewWorkflowsExcluded: false }]) mockSumForkCopyBytes.mockResolvedValue(0) mockAssertForkStorageHeadroom.mockResolvedValue(undefined) mockLoadSourceDeployedStates.mockResolvedValue({ @@ -210,6 +215,10 @@ describe('createFork storage headroom gate', () => { it('refuses when the parent changed organizations after the policy was captured', async () => { resetDbChainMock() + queueTableRows(projectWorkspace, [{ projectId: 'project-source' }]) + queueTableRows(projectWorkspace, [ + { project: { id: 'project-source', organizationId: null, archivedAt: null } }, + ]) /** * `assertCanFork` captures `policy.organizationId` before this transaction, * so an admin workspace move committing in between would otherwise leave @@ -234,7 +243,12 @@ describe('createFork storage headroom gate', () => { */ it('gives the child the source workspace personal API-key and fork-sync policies', async () => { resetDbChainMock() + queueTableRows(projectWorkspace, [{ projectId: 'project-source' }]) + queueTableRows(projectWorkspace, [ + { project: { id: 'project-source', organizationId: null, archivedAt: null } }, + ]) queueTableRows(workspace, [{ organizationId: null, forkSyncNewWorkflowsExcluded: true }]) + queueTableRows(workspace, [{ archivedAt: null, forkSyncNewWorkflowsExcluded: true }]) const result = await createFork(forkParams()) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 8b8a06bbb77..995652275e4 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { permissions, workflow, workspace } from '@sim/db/schema' +import { permissions, projectWorkspace, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import type { PermissionType } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' @@ -7,6 +7,7 @@ import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { Workspace } from '@/lib/api/contracts/workspaces' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { requireForkProject } from '@/lib/projects/membership' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -231,6 +232,8 @@ export async function createFork(params: CreateForkParams): Promise workspaceForkin import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink' -const { mockSetForkLockTimeout, mockAcquireForkEdgeLock } = workspaceForkingLineageMockFns const { mockResolveForkLineageRootId } = workspaceForkingLineageRootMockFns const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' } @@ -27,20 +23,6 @@ describe('unlinkForkEdge', () => { mockResolveForkLineageRootId.mockResolvedValue('root-ws') }) - it('detaches the child under its edge lock', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'child-ws' }]) - - const result = await unlinkForkEdge(EDGE, 'req-1') - - expect(result).toEqual({ unlinked: true }) - expect(mockSetForkLockTimeout).toHaveBeenCalledTimes(1) - expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(dbChainMock.db, 'child-ws') - expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ forkedFromWorkspaceId: null }) - ) - }) - /** A root moved by a concurrent unlink higher up means the lock taken no longer covers it. */ it('refuses when the lineage root moved before the lock was taken', async () => { mockResolveForkLineageRootId.mockResolvedValueOnce('root-ws').mockResolvedValueOnce('parent-ws') diff --git a/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts b/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts index 6f77fee413f..bfd3bee308a 100644 --- a/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts +++ b/apps/sim/ee/workspace-forking/lib/lineage/unlink.ts @@ -9,6 +9,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { splitForkProject } from '@/lib/projects/membership' import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' import { acquireForkEdgeLock, @@ -28,8 +29,7 @@ export interface UnlinkForkResult { /** * Permanently dissolve a fork edge: null the child's `forkedFromWorkspaceId` (the * edge's single source of truth) and purge the edge's fork state — resource map, - * block map, dependent values, and promote-run undo points. Both workspaces are - * left untouched; only the association and its metadata are removed. + * block map, dependent values, and promote-run undo points. Both workspaces remain; the detached subtree moves into its own Project. * * Runs in one transaction under the lineage and edge advisory locks. Every promote and * rollback on the edge holds the edge lock, so an in-flight sync either finishes before @@ -63,6 +63,14 @@ export async function unlinkForkEdge( } await acquireForkEdgeLock(tx, childWorkspaceId) + const [currentEdge] = await tx + .select({ parentId: workspace.forkedFromWorkspaceId }) + .from(workspace) + .where(eq(workspace.id, childWorkspaceId)) + .limit(1) + if (currentEdge?.parentId !== parentWorkspaceId) return false + await splitForkProject(tx, childWorkspaceId) + const updated = await tx .update(workspace) .set({ forkedFromWorkspaceId: null, updatedAt: new Date() }) diff --git a/apps/sim/hooks/queries/projects.ts b/apps/sim/hooks/queries/projects.ts new file mode 100644 index 00000000000..d95b21a4a42 --- /dev/null +++ b/apps/sim/hooks/queries/projects.ts @@ -0,0 +1,28 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { isApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { listProjectsContract } from '@/lib/api/contracts/projects' + +const PROJECT_LIST_STALE_TIME = 30_000 +const projectKeys = { + all: ['projects'] as const, + lists: () => [...projectKeys.all, 'list'] as const, + list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const, +} + +export function useProjects(organizationId: string) { + return useInfiniteQuery({ + queryKey: projectKeys.list(organizationId), + initialPageParam: null as string | null, + queryFn: ({ signal, pageParam }) => + requestJson(listProjectsContract, { + query: { organizationId, cursor: pageParam ?? undefined, limit: 100 }, + signal, + }), + getNextPageParam: (page) => page.nextCursor, + staleTime: PROJECT_LIST_STALE_TIME, + retry: (failureCount, error) => + !(isApiClientError(error) && error.status === 503) && failureCount < 3, + enabled: Boolean(organizationId), + }) +} diff --git a/apps/sim/lib/api/contracts/projects.ts b/apps/sim/lib/api/contracts/projects.ts new file mode 100644 index 00000000000..f25549855e0 --- /dev/null +++ b/apps/sim/lib/api/contracts/projects.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { createProjectInputSchema } from '@/lib/projects/create-input' + +export const projectEnvironmentSchema = z.object({ + id: nonEmptyIdSchema, + name: z.string(), + forkedFromWorkspaceId: nonEmptyIdSchema.nullable(), +}) +export type ProjectEnvironment = z.output +export const projectSchema = z.object({ + id: nonEmptyIdSchema, + name: z.string(), + organizationId: nonEmptyIdSchema.nullable(), + ownerId: nonEmptyIdSchema, + archivedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + environments: z.array(projectEnvironmentSchema), + capabilities: z.object({ administer: z.boolean(), issues: z.boolean() }), +}) +export type Project = z.output +export const projectParamsSchema = z.object({ id: nonEmptyIdSchema }) +export type ProjectParams = z.input +export const projectQuerySchema = z.object({ + organizationId: nonEmptyIdSchema.optional(), + workspaceId: nonEmptyIdSchema.optional(), +}) +export type ProjectQuery = z.input +export const listProjectsQuerySchema = z.object({ + organizationId: nonEmptyIdSchema.optional(), + cursor: nonEmptyIdSchema.optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}) +export type ListProjectsQuery = z.input +export const listProjectsResponseSchema = z.object({ + projects: z.array(projectSchema), + nextCursor: nonEmptyIdSchema.nullable(), +}) +export type ListProjectsResponse = z.output +export const listProjectsContract = defineRouteContract({ + method: 'GET', + path: '/api/projects', + query: listProjectsQuerySchema, + response: { mode: 'json', schema: listProjectsResponseSchema }, +}) +export const getProjectResponseSchema = z.object({ project: projectSchema }) +export type GetProjectResponse = z.output +export const getProjectContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]', + params: projectParamsSchema, + query: projectQuerySchema, + response: { mode: 'json', schema: getProjectResponseSchema }, +}) +export const renameProjectBodySchema = z.object({ name: z.string().trim().min(1).max(100) }) +export type RenameProjectBody = z.input +export const renameProjectResponseSchema = z.object({ id: nonEmptyIdSchema, name: z.string() }) +export type RenameProjectResponse = z.output +export const renameProjectContract = defineRouteContract({ + method: 'PATCH', + path: '/api/projects/[id]', + params: projectParamsSchema, + body: renameProjectBodySchema, + response: { mode: 'json', schema: renameProjectResponseSchema }, +}) +export const archiveProjectResponseSchema = z.object({ + id: nonEmptyIdSchema, + archived: z.boolean(), +}) +export type ArchiveProjectResponse = z.output +export const archiveProjectContract = defineRouteContract({ + method: 'DELETE', + path: '/api/projects/[id]', + params: projectParamsSchema, + response: { mode: 'json', schema: archiveProjectResponseSchema }, +}) + +export const workspaceProjectParamsSchema = z.object({ workspaceId: nonEmptyIdSchema }) +export type WorkspaceProjectParams = z.input +export const getWorkspaceProjectContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/by-workspace/[workspaceId]', + params: workspaceProjectParamsSchema, + response: { mode: 'json', schema: getProjectResponseSchema }, +}) + +export const createProjectBodySchema = createProjectInputSchema +export type CreateProjectBody = z.input +export const createProjectResponseSchema = z.object({ + project: z.object({ id: nonEmptyIdSchema, name: z.string() }), + initialEnvironment: z.object({ id: nonEmptyIdSchema, name: z.string() }), +}) +export type CreateProjectResponse = z.output +export const createProjectContract = defineRouteContract({ + method: 'POST', + path: '/api/projects', + body: createProjectBodySchema, + response: { mode: 'json', status: 201, schema: createProjectResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index fdb25a3ddfa..458e2d736ea 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -477,6 +477,7 @@ export const accountDeletionBlockerSchema = z.object({ 'shared_workspace', 'organization_workspace', 'data_drain_owner', + 'project_lifecycle', ]), /** A sentence naming both the obstacle and the way out. */ message: z.string(), diff --git a/apps/sim/lib/billing/organizations/lock-order.test.ts b/apps/sim/lib/billing/organizations/lock-order.test.ts index 11aa771177f..108bb594f63 100644 --- a/apps/sim/lib/billing/organizations/lock-order.test.ts +++ b/apps/sim/lib/billing/organizations/lock-order.test.ts @@ -228,6 +228,7 @@ describe('workspace payer-change transaction lock ordering', () => { const tx = { execute: async () => [], select, + selectDistinct: select, insert: () => ({ values: () => ({ onConflictDoUpdate: async () => undefined, diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index ce5181d8a27..220b0dba82d 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -15,6 +15,7 @@ import { organization, permissionGroupMember, permissions, + project, subscription as subscriptionTable, user, userStats, @@ -60,6 +61,7 @@ import { revokePersonalApiKeysTx, revokeUserSessionsTx, } from '@/lib/organizations/members/revocation' +import { lockProjectBackfillWrites, tryLockProject } from '@/lib/projects/membership' import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -541,7 +543,7 @@ async function reassignOwnedOrganizationResourcesTx({ organizationId, workspaceIds, }: { - tx: DbOrTx + tx: DbTransaction userId: string organizationId: string workspaceIds: string[] @@ -555,6 +557,26 @@ async function reassignOwnedOrganizationResourcesTx({ const ownerId = ownerMembership?.userId if (!ownerId || ownerId === userId) return 0 + await lockProjectBackfillWrites(tx, workspaceIds) + const ownedProjects = await tx + .select({ id: project.id }) + .from(project) + .where(and(eq(project.organizationId, organizationId), eq(project.ownerId, userId))) + .orderBy(project.id) + for (const row of ownedProjects) { + await tryLockProject(tx, row.id) + await tx + .update(project) + .set({ ownerId, updatedAt: new Date() }) + .where( + and( + eq(project.id, row.id), + eq(project.ownerId, userId), + eq(project.organizationId, organizationId) + ) + ) + } + /** Creator attribution must survive account deletion without changing document ACLs. */ await tx .update(knowledgeBase) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7f232d483bb..9904b0143e3 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -669,6 +669,7 @@ export const env = createEnv({ // SSO Configuration (for script-based registration) SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality + PROJECT_API_ENABLED: z.boolean().optional(), // Expose Projects after backfill and contract enforcement SCIM_ENABLED: z.boolean().optional(), // Enable SCIM directory provisioning USAGE_MONITORING_ENABLED: z.boolean().optional(), // Enable organization usage monitoring on self-hosted (bypasses hosted requirements) SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type diff --git a/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts b/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts index eee347e4058..3d99262cb32 100644 --- a/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-source-setup.integration.ts @@ -6,6 +6,7 @@ import { knowledgeConnector, mcpServers, permissions, + project, resourcePolicy, user, workspace, @@ -96,6 +97,7 @@ describe('Search source identity and concurrent creation', () => { await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) await db.delete(workspace).where(eq(workspace.id, other.workspaceId)) for (const id of [ids.aliceId, ids.bobId, other.aliceId, other.bobId]) { + await db.delete(project).where(eq(project.ownerId, id)) await db.delete(user).where(eq(user.id, id)) } }) diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index 8260dea111d..ab3743d91a2 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -58,6 +58,7 @@ export const CAPABILITY_IDS = [ 'copilot.tool_auto_approval', 'sandboxes.use', 'knowledge.export', + 'project_issues.use', ] as const export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] @@ -431,6 +432,14 @@ export const CAPABILITY_RULES = { describe: 'Exporting a knowledge base', deniedBy: (config) => config.disableKnowledgeBaseExport || config.hideKnowledgeBaseTab, }, + 'project_issues.use': { + kind: 'parameterized', + configKeys: ['deniedPartialAccessProjectIssues'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Project Issues access', + deniedBy: (config: PermissionGroupConfig, projectId: string) => + config.deniedPartialAccessProjectIssues.includes(projectId), + }, } satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule } /** diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts index 45f99a25312..bd3070defc5 100644 --- a/apps/sim/lib/permission-groups/fields.test.ts +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -163,6 +163,7 @@ const fixtures: readonly CoercionFixture[] = [ hideSandboxesTab: true, disableOAuthAppAccess: true, disableKnowledgeBaseExport: true, + deniedPartialAccessProjectIssues: ['project-a'], }, expected: { allowedIntegrations: ['slack_v2'], @@ -207,6 +208,7 @@ const fixtures: readonly CoercionFixture[] = [ hideSandboxesTab: true, disableOAuthAppAccess: true, disableKnowledgeBaseExport: true, + deniedPartialAccessProjectIssues: ['project-a'], }, }, ] diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 8bb73786daf..0ceb1036c70 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -506,6 +506,11 @@ export const PERMISSION_GROUP_FIELDS = { category: 'Knowledge Base', hint: 'Prevent downloading a whole knowledge base as an archive.', }), + deniedPartialAccessProjectIssues: denylist( + z.string().min(1).max(255), + 'capability', + 'Issues in the listed Projects are unavailable to teammates without access to every active environment.' + ), } satisfies Record export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS diff --git a/apps/sim/lib/permission-groups/group-manager.ts b/apps/sim/lib/permission-groups/group-manager.ts index 3c492e42f09..db529c1ddfa 100644 --- a/apps/sim/lib/permission-groups/group-manager.ts +++ b/apps/sim/lib/permission-groups/group-manager.ts @@ -1,7 +1,12 @@ import { db } from '@sim/db' -import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' +import { + permissionGroup, + permissionGroupMember, + permissionGroupWorkspace, + project, +} from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { @@ -54,6 +59,23 @@ async function validateWorkspaces( ) } +async function validateProjectRestrictions( + organizationId: string, + ids: string[] | undefined, + tx: DbOrTx +) { + if (!ids?.length) return + const projects = await tx + .select({ id: project.id }) + .from(project) + .where(and(eq(project.organizationId, organizationId), inArray(project.id, ids))) + if (projects.length !== new Set(ids).size) + throw new OrchestrationError( + 'validation', + 'A restricted Project does not belong to this organization' + ) +} + async function assertAvailableName( organizationId: string, name: string, @@ -115,6 +137,11 @@ export async function createPermissionGroupRecord( 'Select at least one workspace when the group targets specific workspaces' ) return withPermissionGroupMutation(organizationId, async (tx) => { + await validateProjectRestrictions( + organizationId, + input.config?.deniedPartialAccessProjectIssues, + tx + ) await validateWorkspaces(organizationId, workspaceIds, tx) await assertAvailableName(organizationId, input.name, tx) const now = new Date() @@ -151,6 +178,11 @@ export async function updatePermissionGroupRecord( updates: PermissionGroupChanges ) { return withPermissionGroupMutation(organizationId, async (tx) => { + await validateProjectRestrictions( + organizationId, + updates.config?.deniedPartialAccessProjectIssues, + tx + ) const group = await requirePermissionGroup(organizationId, groupId, tx) if (updates.name !== undefined) await assertAvailableName(organizationId, updates.name, tx, groupId) diff --git a/apps/sim/lib/projects/README.md b/apps/sim/lib/projects/README.md new file mode 100644 index 00000000000..88c3a330a9f --- /dev/null +++ b/apps/sim/lib/projects/README.md @@ -0,0 +1,78 @@ +# Project creation + +A Project groups environments. An environment is an existing `workspace` record; there is no separate environment table. Every newly created Project starts with an environment. + +Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`. Workspace creation always assigns a Project atomically. The API control defaults off and does not disable assignment. Existing assigned Projects always retain their lifecycle protections, including fork inheritance and disconnect behavior, even if activation is disabled. + +## Choose the creation flow + +| Caller | Flow | Result | +| --- | --- | --- | +| New Project onboarding | `POST /api/projects` | Creates a Project and its first environment together, with independently supplied names. | +| Existing workspace creation UI or caller | `POST /api/workspaces` | Creates a workspace and automatically creates its Project, preserving the existing workspace response. | +| Create another environment by forking | Existing workspace fork operation | Inherits the source workspace's Project; unassigned legacy families remain unassigned until backfilled. | + +Both POST endpoints are internal, session-authenticated APIs. `POST /api/projects` is not a public `/api/v2` endpoint and does not accept API-key principals. This foundation does not remove or deprecate existing workspace creation endpoints. + +Do not call both creation endpoints for one onboarding flow: each creates a new workspace and a new Project. Neither endpoint attaches a workspace to an existing Project. A general Project environment-creation endpoint is follow-up work. + +## Create a Project and its first environment + +Use the shared client and contract for same-origin application calls: + +```ts +import { requestJson } from '@/lib/api/client/request' +import { createProjectContract } from '@/lib/api/contracts/projects' + +const result = await requestJson(createProjectContract, { + body: { + organizationId: selectedOrganizationId, + name: 'Customer support', + initialEnvironment: { name: 'Production' }, + }, +}) +``` + +All three inputs are required: + +- `organizationId`: the intended organization's ID, or explicitly `null` for a personal Project. There is no fallback to the session's active organization. The caller must be eligible to create in the requested scope; an ineligible organization request is not silently converted to personal creation. +- `name`: the Project name, trimmed and limited to 1–100 characters. +- `initialEnvironment.name`: the first environment's name, also trimmed and limited to 1–100 characters. There is **no default environment name**; `Production` above is an example supplied by the caller. + +For personal creation, use the same request with `organizationId: null`. + +The HTTP 201 response contains both IDs and names: + +```json +{ + "project": { "id": "", "name": "Customer support" }, + "initialEnvironment": { "id": "", "name": "Production" } +} +``` + +`initialEnvironment.id` is the workspace ID for existing workspace routes and navigation. + +The application operation creates the Project, workspace, Project membership, initial administrator permissions, and starter workflow in one database transaction. A failed transaction leaves none of these new records committed. This endpoint always includes the starter workflow; it has no `skipDefaultWorkflow` option. + +Creation uses existing workspace eligibility, billing, and `workspace.create` permission-group rules. Choosing personal scope does not bypass applicable organization permission-group restrictions. This endpoint has no idempotency-key support: resubmitting after an uncertain network result can create another Project, so do not blindly retry. + +## Existing workspace creation + +Existing callers can continue to use `createWorkspaceContract` and `POST /api/workspaces` with their current body: + +```json +{ + "name": "Support workspace", + "skipDefaultWorkflow": false +} +``` + +`skipDefaultWorkflow` remains optional and defaults to `false`. The route uses the session's active organization and existing workspace creation policy to resolve ownership and billing. It does not take the explicit Project scope or independent Project name used by `POST /api/projects`. + +The workspace and its Project are created atomically. The generated Project name is `Support workspace - Project`; long names are bounded to 100 characters while retaining the suffix. The response remains `{ "workspace": ... }` with HTTP 200, without a new Project response wrapper. Call `GET /api/projects/by-workspace/[workspaceId]` when an existing workspace caller needs its authorized Project details. + +## Server implementation + +The Project route calls `createProject` in `application/create-project.ts`. Existing workspace callers continue through their current creation paths. Both use the shared transaction primitive in `lib/workspaces/create.ts`; surface adapters must not independently commit Project and workspace creation. + +Project descriptions and Project-scoped files are not part of this creation contract. Project-scoped files and a designated Project brief are follow-up work. diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts new file mode 100644 index 00000000000..876079dc5d7 --- /dev/null +++ b/apps/sim/lib/projects/__integration__/foundation.integration.ts @@ -0,0 +1,1309 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { db } from '@sim/db' +import { + member, + organization, + permissionGroup, + permissions, + project, + projectWorkspace, + user, + userStats, + workflow, + workflowBlocks, + workspace, + workspaceForkBlockMap, + workspaceForkDependentValue, + workspaceForkPromoteRun, + workspaceForkResourceMap, +} from '@sim/db/schema' +import { + createSessionPrincipal, + createWorkspaceApiKeyPrincipal, +} from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion' +import { + archiveProject, + createProject, + getProject, + getProjectIssueAccess, + getWorkspaceProject, + listProjects, + renameProject, +} from '@/lib/projects/application' +import { archiveProjectInTransaction } from '@/lib/projects/lifecycle' +import { + createProjectForWorkspace, + lockProject, + lockWorkspaceProject, + splitForkProject, + transferWorkspaceProjects, +} from '@/lib/projects/membership' +import { getAccountDeletionPlan } from '@/lib/users/account-deletion' +import { disableUserResources, restoreWorkflow } from '@/lib/workflows/lifecycle' +import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' +import { createWorkspaceInTransaction } from '@/lib/workspaces/create' +import { archiveWorkspace } from '@/lib/workspaces/lifecycle' +import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceCreationPolicy } from '@/lib/workspaces/policy' +import { POST as importAdminWorkflow } from '@/app/api/v1/admin/workflows/import/route' +import { createFork } from '@/ee/workspace-forking/lib/create-fork' +import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink' + +vi.hoisted(() => { + process.env.ADMIN_API_KEY = 'project-fixture-admin-key' +}) + +beforeEach(() => { + vi.stubEnv('PROJECT_API_ENABLED', 'true') +}) + +const users: string[] = [] +const organizations: string[] = [] +const environments: string[] = [] +const request = { requestId: 'project-foundation-integration', headers: new Headers() } +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +/** Exercises durable auth and lifecycle invariants against real Postgres, including concurrent writers. */ +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture(org = true, count = 2) { + const ownerId = generateId() + const teammateId = generateId() + const outsiderId = generateId() + const now = new Date() + for (const id of [ownerId, teammateId, outsiderId]) { + users.push(id) + await db.insert(user).values({ + id, + email: `${id}@projects.invalid`, + name: 'Project fixture', + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + } + await db.insert(userStats).values({ id: generateId(), userId: ownerId }) + const organizationId = org ? generateId() : null + if (organizationId) { + organizations.push(organizationId) + await db + .insert(organization) + .values({ id: organizationId, name: 'Project fixture', slug: organizationId, createdAt: now }) + await db + .insert(member) + .values({ id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: now }) + } + const ids = Array.from({ length: count }, () => generateId()) + let projectId = '' + await db.transaction(async (tx) => { + for (const [index, id] of ids.entries()) { + environments.push(id) + await tx.insert(workspace).values({ + id, + name: `Environment ${index}`, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: organizationId ? 'organization' : 'grandfathered_shared', + forkedFromWorkspaceId: index ? ids[index - 1] : null, + }) + await tx.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: id, + userId: ownerId, + permissionType: 'admin', + }) + if (!index) + projectId = await createProjectForWorkspace(tx, { + workspaceId: id, + name: 'Environment 0', + organizationId, + ownerId, + }) + else await tx.insert(projectWorkspace).values({ projectId, workspaceId: id }) + } + }) + const owner = createSessionPrincipal({ userId: ownerId }) + const teammate = createSessionPrincipal({ userId: teammateId }) + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: ids[0], + userId: teammateId, + permissionType: 'admin', + }) + return { ownerId, teammateId, outsiderId, organizationId, projectId, ids, owner, teammate } +} + +async function addWorkflow(workspaceId: string, userId: string) { + const id = generateId() + const now = new Date() + await db.insert(workflow).values({ + id, + workspaceId, + userId, + name: 'Scheduled work', + createdAt: now, + updatedAt: now, + lastSynced: now, + isDeployed: true, + isPublicApi: true, + }) + return id +} + +afterAll(async () => { + const reportPath = + process.env.PROJECT_FOUNDATION_REPORT_PATH ?? resolve('test-results/project-foundation.json') + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) + if (environments.length) { + const memberships = await db + .select({ id: projectWorkspace.projectId }) + .from(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, environments)) + const ids = [...new Set(memberships.map((row) => row.id))] + await db.delete(projectWorkspace).where(inArray(projectWorkspace.workspaceId, environments)) + if (ids.length) await db.delete(project).where(inArray(project.id, ids)) + await db.delete(workspace).where(inArray(workspace.id, environments)) + } + if (organizations.length) + await db.delete(organization).where(inArray(organization.id, organizations)) + if (users.length) await db.delete(user).where(inArray(user.id, users)) +}) + +describe('Project foundation at the database and application boundary', () => { + check( + 'workspace creation and fork/disconnect assign Projects while APIs remain disabled', + async () => { + vi.stubEnv('PROJECT_API_ENABLED', 'false') + const f = await fixture(false, 1) + const source = await db.transaction((tx) => + createWorkspaceInTransaction(tx, { + userId: f.ownerId, + name: 'Legacy source', + organizationId: null, + observedOrganizationId: null, + governingPermissionGroupOrganizationId: null, + workspaceMode: 'personal', + billedAccountUserId: f.ownerId, + skipDefaultWorkflow: true, + }) + ) + environments.push(source.id) + expect( + await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, source.id)) + ).toHaveLength(1) + const parent = await getWorkspaceWithOwner(source.id) + if (!parent) throw new Error('Missing source fixture') + const fork = await createFork({ + source: parent, + policy: await getWorkspaceCreationPolicy({ userId: f.ownerId }), + userId: f.ownerId, + name: 'Legacy child', + }) + environments.push(fork.workspace.id) + expect( + await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) + ).toHaveLength(1) + await unlinkForkEdge({ parentWorkspaceId: source.id, childWorkspaceId: fork.workspace.id }) + const [child] = await db.select().from(workspace).where(eq(workspace.id, fork.workspace.id)) + expect(child.forkedFromWorkspaceId).toBeNull() + expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(3) + } + ) + + check( + 'Project operations remain unavailable until API activation with no partial creation', + async () => { + const f = await fixture(false, 1) + vi.stubEnv('PROJECT_API_ENABLED', 'false') + const input = { projectId: f.projectId } + const calls = [ + () => + createProject.execute({ + principal: f.owner, + input: { + organizationId: null, + name: 'Blocked', + initialEnvironment: { name: 'Production' }, + }, + request, + }), + () => getProject.execute({ principal: f.owner, input, request }), + () => + getWorkspaceProject.execute({ + principal: f.owner, + input: { workspaceId: f.ids[0] }, + request, + }), + () => listProjects.execute({ principal: f.owner, input: { limit: 10 }, request }), + () => + renameProject.execute({ + principal: f.owner, + input: { ...input, name: 'Blocked' }, + request, + }), + () => archiveProject.execute({ principal: f.owner, input, request }), + () => getProjectIssueAccess.execute({ principal: f.owner, input, request }), + ] + for (const call of calls) await expect(call()).rejects.toMatchObject({ statusCode: 503 }) + expect( + await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId)) + ).toHaveLength(1) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.name).toBe('Environment 0 - Project') + expect(record.archivedAt).toBeNull() + } + ) + + check( + 'disabling activation preserves assigned fork membership and lifecycle protections', + async () => { + const f = await fixture(false, 1) + vi.stubEnv('PROJECT_API_ENABLED', 'false') + const parent = await getWorkspaceWithOwner(f.ids[0]) + if (!parent) throw new Error('Missing source fixture') + const fork = await createFork({ + source: parent, + policy: await getWorkspaceCreationPolicy({ userId: f.ownerId }), + userId: f.ownerId, + name: 'Assigned child', + }) + environments.push(fork.workspace.id) + const [membership] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) + expect(membership.projectId).toBe(f.projectId) + await unlinkForkEdge({ parentWorkspaceId: f.ids[0], childWorkspaceId: fork.workspace.id }) + const [detached] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) + expect(detached.projectId).not.toBe(f.projectId) + await expect( + archiveWorkspace(fork.workspace.id, { requestId: 'disabled-project-rollout' }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check('new workspaces receive Projects while Project APIs remain disabled', async () => { + vi.stubEnv('PROJECT_API_ENABLED', 'false') + const f = await fixture(false, 1) + const created = await db.transaction((tx) => + createWorkspaceInTransaction(tx, { + userId: f.ownerId, + name: 'Writer activation', + organizationId: null, + observedOrganizationId: null, + governingPermissionGroupOrganizationId: null, + workspaceMode: 'personal', + billedAccountUserId: f.ownerId, + skipDefaultWorkflow: true, + }) + ) + environments.push(created.id) + expect( + await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, created.id)) + ).toHaveLength(1) + }) + + check('legacy fork and disconnect refuse a partially assigned subtree', async () => { + const f = await fixture(false, 3) + await db + .delete(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, f.ids.slice(0, 2))) + vi.stubEnv('PROJECT_API_ENABLED', 'false') + const parent = await getWorkspaceWithOwner(f.ids[1]) + if (!parent) throw new Error('Missing source fixture') + await expect( + createFork({ + source: parent, + policy: await getWorkspaceCreationPolicy({ userId: f.ownerId }), + userId: f.ownerId, + name: 'Invalid child', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + await expect( + unlinkForkEdge({ parentWorkspaceId: f.ids[0], childWorkspaceId: f.ids[1] }) + ).rejects.toMatchObject({ code: 'conflict' }) + const [child] = await db.select().from(workspace).where(eq(workspace.id, f.ids[1])) + expect(child.forkedFromWorkspaceId).toBe(f.ids[0]) + expect(await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))).toHaveLength( + 3 + ) + }) + + check( + 'legacy membership decisions exclude backfill until the writing transaction commits', + async () => { + const f = await fixture(false, 1) + await db.delete(projectWorkspace).where(eq(projectWorkspace.workspaceId, f.ids[0])) + await db.delete(project).where(eq(project.id, f.projectId)) + const read = createDeferred() + const release = createDeferred() + const writer = db.transaction(async (tx) => { + expect(await lockWorkspaceProject(tx, f.ids[0])).toBeNull() + read.resolve() + await release.promise + await tx + .update(workspace) + .set({ name: 'Concurrent legacy edit' }) + .where(eq(workspace.id, f.ids[0])) + }) + try { + await Promise.race([read.promise, writer]) + await db.transaction(async (tx) => { + const [lock] = await tx.execute<{ acquired: boolean }>(sql` + SELECT pg_try_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0)) AS acquired + `) + expect(lock.acquired).toBe(false) + const [unrelated] = await tx.execute<{ acquired: boolean }>(sql` + SELECT pg_try_advisory_xact_lock(hashtextextended('project-backfill:unrelated', 0)) AS acquired + `) + expect(unrelated.acquired).toBe(true) + }) + } finally { + release.resolve() + await writer + } + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))` + ) + await createProjectForWorkspace(tx, { + workspaceId: f.ids[0], + name: 'Concurrent legacy edit', + organizationId: null, + ownerId: f.ownerId, + }) + }) + expect( + await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, f.ids[0])) + ).toHaveLength(1) + } + ) + + check('a fork waiting for backfill inherits membership committed before it resumes', async () => { + const f = await fixture(false, 1) + await db.delete(projectWorkspace).where(eq(projectWorkspace.workspaceId, f.ids[0])) + await db.delete(project).where(eq(project.id, f.projectId)) + const parent = await getWorkspaceWithOwner(f.ids[0]) + if (!parent) throw new Error('Missing source fixture') + const policy = await getWorkspaceCreationPolicy({ userId: f.ownerId }) + const locked = createDeferred() + const release = createDeferred() + const backfill = db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))` + ) + locked.resolve() + await release.promise + return createProjectForWorkspace(tx, { + workspaceId: f.ids[0], + name: 'Backfilled source', + organizationId: null, + ownerId: f.ownerId, + }) + }) + await Promise.race([locked.promise, backfill]) + const fork = createFork({ + source: parent, + policy, + userId: f.ownerId, + name: 'Concurrent child', + }).then((result) => { + environments.push(result.workspace.id) + return result + }) + let blocked = false + try { + for (let attempt = 0; attempt < 100; attempt++) { + const rows = await db.execute<{ waiting: boolean }>(sql`SELECT EXISTS ( + SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND mode = 'ShareLock' AND NOT granted + ) AS waiting`) + if (rows[0]?.waiting) { + blocked = true + break + } + await sleep(20) + } + } finally { + release.resolve() + } + const [projectId, result] = await Promise.all([backfill, fork]) + expect(blocked).toBe(true) + const [membership] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, result.workspace.id)) + expect(membership.projectId).toBe(projectId) + }) + + check( + 'Project creation commits its named first environment and starter workflow in the requested scope', + async () => { + for (const personal of [false, true]) { + const f = await fixture(true, 1) + const organizationId = personal ? null : f.organizationId + if (!f.organizationId) throw new Error('Missing organization fixture') + await db.insert(member).values({ + id: generateId(), + organizationId: f.organizationId, + userId: f.teammateId, + role: 'admin', + createdAt: new Date(), + }) + const result = await createProject.execute({ + principal: f.teammate, + input: { + organizationId, + name: 'Customer support', + initialEnvironment: { name: 'Production' }, + }, + request, + }) + environments.push(result.initialEnvironment.id) + const [created] = await db.select().from(project).where(eq(project.id, result.project.id)) + expect(created).toMatchObject({ + name: 'Customer support', + organizationId, + ownerId: f.teammateId, + }) + const [env] = await db + .select() + .from(workspace) + .where(eq(workspace.id, result.initialEnvironment.id)) + expect(env).toMatchObject({ + name: 'Production', + organizationId, + ownerId: f.teammateId, + billedAccountUserId: personal ? f.teammateId : f.ownerId, + }) + expect( + await db + .select({ + projectId: projectWorkspace.projectId, + workspaceId: projectWorkspace.workspaceId, + }) + .from(projectWorkspace) + .where(eq(projectWorkspace.projectId, created.id)) + ).toEqual([{ projectId: created.id, workspaceId: env.id }]) + const grants = await db.select().from(permissions).where(eq(permissions.entityId, env.id)) + expect( + grants.map((grant) => ({ userId: grant.userId, permissionType: grant.permissionType })) + ).toEqual( + expect.arrayContaining([ + { userId: f.teammateId, permissionType: 'admin' }, + ...(personal ? [] : [{ userId: f.ownerId, permissionType: 'admin' }]), + ]) + ) + expect(grants).toHaveLength(personal ? 1 : 2) + const workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, env.id)) + expect(workflows).toHaveLength(1) + const blocks = await db + .select() + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflows[0].id)) + expect(blocks.length).toBeGreaterThan(0) + } + } + ) + + check( + 'Project creation refuses workspace keys and foreign organizations without creating resources', + async () => { + const f = await fixture(true, 1) + const foreign = await fixture(true, 1) + const input = { + organizationId: foreign.organizationId, + name: 'Forbidden project', + initialEnvironment: { name: 'Production' }, + } + await expect( + createProject.execute({ principal: f.owner, input, request }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + createProject.execute({ + principal: createWorkspaceApiKeyPrincipal({ workspaceId: f.ids[0] }), + input: { ...input, organizationId: f.organizationId }, + request, + }) + ).rejects.toThrow('cannot perform operation') + expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(1) + expect( + await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId)) + ).toHaveLength(1) + } + ) + + check( + 'Project creation cannot bypass the workspace-creation restriction through personal scope', + async () => { + const f = await fixture(true, 1) + if (!f.organizationId) throw new Error('Missing organization fixture') + await db.insert(permissionGroup).values({ + id: generateId(), + organizationId: f.organizationId, + name: 'Creation disabled', + createdBy: f.ownerId, + isDefault: true, + config: { disableWorkspaceCreation: true }, + }) + for (const organizationId of [f.organizationId, null]) { + await expect( + createProject.execute({ + principal: f.owner, + input: { + organizationId, + name: 'Forbidden project', + initialEnvironment: { name: 'Production' }, + }, + request, + }) + ).rejects.toMatchObject({ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + } + expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(1) + expect( + await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId)) + ).toHaveLength(1) + } + ) + + check( + 'A starter-workflow failure rolls back the Project, environment and permissions together', + async () => { + const f = await fixture(false, 1) + const constraint = sql.identifier(`project_create_test_${generateId().replaceAll('-', '')}`) + const before = await db.select().from(permissions).where(eq(permissions.userId, f.ownerId)) + await db.execute( + sql`ALTER TABLE ${workflow} ADD CONSTRAINT ${constraint} CHECK (${workflow.userId} <> ${f.ownerId}) NOT VALID`.inlineParams() + ) + try { + await expect( + createProject.execute({ + principal: f.owner, + input: { + organizationId: null, + name: 'Rollback project', + initialEnvironment: { name: 'Rollback environment' }, + }, + request, + }) + ).rejects.toSatisfy((error: unknown) => getPostgresErrorCode(error) === '23514') + expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength( + 1 + ) + expect( + await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId)) + ).toHaveLength(1) + expect( + await db.select().from(permissions).where(eq(permissions.userId, f.ownerId)) + ).toEqual(before) + } finally { + await db.execute(sql`ALTER TABLE ${workflow} DROP CONSTRAINT ${constraint}`) + } + } + ) + + check('workspace creation and forks commit exactly one Project membership', async () => { + const f = await fixture(false, 1) + const source = await db.transaction((tx) => + createWorkspaceInTransaction(tx, { + userId: f.ownerId, + name: 'New environment', + organizationId: null, + observedOrganizationId: null, + governingPermissionGroupOrganizationId: null, + workspaceMode: 'personal', + billedAccountUserId: f.ownerId, + skipDefaultWorkflow: true, + }) + ) + environments.push(source.id) + const before = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, source.id)) + expect(before).toHaveLength(1) + const policy = await getWorkspaceCreationPolicy({ userId: f.ownerId }) + const parent = await getWorkspaceWithOwner(source.id) + if (!parent) throw new Error('Missing source fixture') + const fork = await createFork({ + source: parent, + policy, + userId: f.ownerId, + name: 'Child environment', + }) + environments.push(fork.workspace.id) + const child = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) + expect(child).toHaveLength(1) + expect(child[0].projectId).toBe(before[0].projectId) + }) + + check( + 'a departing organization member transfers Project lifecycle ownership to the org owner', + async () => { + const f = await fixture() + const organizationId = f.organizationId + if (!organizationId) throw new Error('Missing organization fixture') + const memberId = generateId() + await db.insert(member).values({ + id: memberId, + organizationId: organizationId, + userId: f.teammateId, + role: 'member', + createdAt: new Date(), + }) + await db.update(project).set({ ownerId: f.teammateId }).where(eq(project.id, f.projectId)) + const result = await removeUserFromOrganization({ + userId: f.teammateId, + organizationId: organizationId, + memberId, + actorUserId: f.ownerId, + skipBillingLogic: true, + onError: 'throw', + }) + expect(result.success).toBe(true) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.ownerId).toBe(f.ownerId) + expect(record.organizationId).toBe(f.organizationId) + } + ) + + check( + 'hides inaccessible environments and denies mismatched scopes and workspace keys', + async () => { + const f = await fixture() + const input = { projectId: f.projectId } + const result = await getProject.execute({ principal: f.teammate, input, request }) + expect(result.project.environments.map((row) => row.id)).toEqual([f.ids[0]]) + expect(result.project.capabilities).toEqual({ administer: false, issues: true }) + await expect( + getWorkspaceProject.execute({ + principal: f.teammate, + input: { workspaceId: f.ids[1] }, + request, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + getProject.execute({ + principal: f.teammate, + input: { ...input, organizationId: generateId() }, + request, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + getProject.execute({ + principal: createSessionPrincipal({ userId: f.outsiderId }), + input, + request, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + getProject.execute({ + principal: createWorkspaceApiKeyPrincipal({ workspaceId: f.ids[0] }), + input, + request, + }) + ).rejects.toThrow() + await expect( + renameProject.execute({ + principal: f.teammate, + input: { ...input, name: 'Renamed' }, + request, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + const list = await listProjects.execute({ + principal: f.teammate, + input: { limit: 1 }, + request, + }) + expect(list.projects.map((row) => row.id)).toEqual([f.projectId]) + } + ) + + check( + 'Issues restriction applies only to partial access and excludes archived environments', + async () => { + const f = await fixture() + const organizationId = f.organizationId + if (!organizationId) throw new Error('Missing organization fixture') + const args = { principal: f.teammate, input: { projectId: f.projectId }, request } + await expect(getProjectIssueAccess.execute(args)).resolves.toEqual({ projectId: f.projectId }) + await db.insert(permissionGroup).values({ + id: generateId(), + organizationId: organizationId, + name: 'Default restrictions', + createdBy: f.ownerId, + isDefault: true, + config: { deniedPartialAccessProjectIssues: [f.projectId] }, + }) + await expect(getProjectIssueAccess.execute(args)).rejects.toMatchObject({ + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + await db.insert(permissions).values({ + id: generateId(), + userId: f.teammateId, + entityType: 'workspace', + entityId: f.ids[1], + permissionType: 'read', + }) + await expect(getProjectIssueAccess.execute(args)).resolves.toEqual({ projectId: f.projectId }) + await db + .delete(permissions) + .where(and(eq(permissions.userId, f.teammateId), eq(permissions.entityId, f.ids[1]))) + await archiveWorkspace(f.ids[1], request) + await expect(getProjectIssueAccess.execute(args)).resolves.toEqual({ projectId: f.projectId }) + await expect( + renameProject.execute({ + ...args, + input: { ...args.input, name: 'Still requires all admins' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + ) + + check( + 'personal ownership does not substitute for access; admins of every environment may rename', + async () => { + const f = await fixture(false) + await db.insert(permissions).values({ + id: generateId(), + userId: f.teammateId, + entityType: 'workspace', + entityId: f.ids[1], + permissionType: 'admin', + }) + await expect( + renameProject.execute({ + principal: f.teammate, + input: { projectId: f.projectId, name: 'Personal project' }, + request, + }) + ).resolves.toEqual({ id: f.projectId, name: 'Personal project' }) + await db.delete(permissions).where(eq(permissions.userId, f.ownerId)) + await expect( + getProject.execute({ principal: f.owner, input: { projectId: f.projectId }, request }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check('concurrent individual removals preserve the last active environment', async () => { + const f = await fixture(false) + const results = await Promise.allSettled(f.ids.map((id) => archiveWorkspace(id, request))) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1) + const rows = await db.select().from(workspace).where(inArray(workspace.id, f.ids)) + expect(rows.filter((row) => !row.archivedAt)).toHaveLength(1) + }) + + check( + 'retrying an environment archive repairs previously unarchived child workflows', + async () => { + const f = await fixture(false) + await archiveWorkspace(f.ids[1], request) + const workflowId = await addWorkflow(f.ids[1], f.ownerId) + expect(await archiveWorkspace(f.ids[1], request)).toMatchObject({ archived: false }) + const [row] = await db.select().from(workflow).where(eq(workflow.id, workflowId)) + expect(row.archivedAt).not.toBeNull() + expect(row.isDeployed).toBe(false) + expect(row.isPublicApi).toBe(false) + } + ) + + check( + 'admin import returns not found when a concurrent archive wins the workspace lock', + async () => { + const f = await fixture(false, 1) + const held = createDeferred() + const release = createDeferred() + const archive = db.transaction(async (tx) => { + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + await archiveProjectInTransaction(tx, f.projectId) + held.resolve(connection.pid) + await release.promise + }) + const blocker = await held.promise + const imported = importAdminWorkflow( + new NextRequest('http://localhost:3000/api/v1/admin/workflows/import', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-admin-key': 'project-fixture-admin-key', + }, + body: JSON.stringify({ + workspaceId: f.ids[0], + workflow: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }), + }), + {} + ) + try { + let waiting = false + for (let attempt = 0; attempt < 100; attempt++) { + const rows = await db.execute( + sql`SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid))` + ) + if (rows.length) { + waiting = true + break + } + await sleep(10) + } + expect(waiting).toBe(true) + } finally { + release.resolve() + await archive + } + expect((await imported).status).toBe(404) + expect(await db.select().from(workflow).where(eq(workflow.workspaceId, f.ids[0]))).toEqual([]) + } + ) + + check('workflow restore cannot overtake a concurrent Project archive', async () => { + const f = await fixture() + const workflowId = await addWorkflow(f.ids[0], f.ownerId) + await db.update(workflow).set({ archivedAt: new Date() }).where(eq(workflow.id, workflowId)) + const held = createDeferred() + const release = createDeferred() + const archive = db.transaction(async (tx) => { + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + await tx.select().from(workflow).where(eq(workflow.id, workflowId)).for('update') + await archiveProjectInTransaction(tx, f.projectId) + held.resolve(connection.pid) + await release.promise + }) + const blocker = await held.promise + const restore = restoreWorkflow(workflowId, request).then( + (result) => result, + (error: unknown) => error + ) + try { + let waiting = false + for (let attempt = 0; attempt < 100; attempt++) { + const rows = await db.execute(sql` + SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid)) + `) + if (rows.length) { + waiting = true + break + } + await sleep(10) + } + expect(waiting).toBe(true) + } finally { + release.resolve() + await archive + } + expect(await restore).toMatchObject({ code: 'not_found' }) + const [row] = await db.select().from(workflow).where(eq(workflow.id, workflowId)) + expect(row.archivedAt).not.toBeNull() + }) + + check( + 'Project archive rolls back as a unit and retries without losing environments', + async () => { + const f = await fixture() + const workflowIds = await Promise.all(f.ids.map((id) => addWorkflow(id, f.ownerId))) + await expect( + db.transaction(async (tx) => { + await archiveProjectInTransaction(tx, f.projectId) + throw new Error('Abort compound archive') + }) + ).rejects.toThrow('Abort compound archive') + const before = await db.select().from(workspace).where(inArray(workspace.id, f.ids)) + expect(before.every((row) => row.archivedAt === null)).toBe(true) + const args = { principal: f.owner, input: { projectId: f.projectId }, request } + await archiveProject.execute(args) + await archiveProject.execute(args) + await expect( + db.transaction(async (tx) => { + const row = await buildNewWorkflowRow(tx, { + id: generateId(), + userId: f.ownerId, + workspaceId: f.ids[0], + folderId: null, + name: 'Late workflow', + description: null, + }) + await tx.insert(workflow).values(row) + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + const rows = await db.select().from(workspace).where(inArray(workspace.id, f.ids)) + expect(rows.every((row) => row.archivedAt !== null)).toBe(true) + const workflows = await db.select().from(workflow).where(inArray(workflow.id, workflowIds)) + expect( + workflows.every((row) => row.archivedAt !== null && !row.isDeployed && !row.isPublicApi) + ).toBe(true) + expect( + await db.select().from(projectWorkspace).where(eq(projectWorkspace.projectId, f.projectId)) + ).toHaveLength(2) + await expect(getProjectIssueAccess.execute(args)).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'disconnect moves descendants to one new Project and preserves restrictions on retry', + async () => { + const f = await fixture(true, 3) + const organizationId = f.organizationId + if (!organizationId) throw new Error('Missing organization fixture') + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: organizationId, + name: 'Restricted', + createdBy: f.ownerId, + isDefault: true, + config: { deniedPartialAccessProjectIssues: [f.projectId] }, + }) + for (const childWorkspaceId of [f.ids[1], f.ids[2]]) { + await db.insert(workspaceForkResourceMap).values({ + id: generateId(), + childWorkspaceId, + resourceType: 'workflow', + parentResourceId: 'parent-workflow', + childResourceId: 'child-workflow', + }) + await db.insert(workspaceForkBlockMap).values({ + id: generateId(), + childWorkspaceId, + parentWorkflowId: 'parent-workflow', + childWorkflowId: 'child-workflow', + parentBlockId: 'parent-block', + childBlockId: 'child-block', + }) + await db.insert(workspaceForkDependentValue).values({ + id: generateId(), + childWorkspaceId, + targetWorkflowId: 'child-workflow', + targetBlockId: 'child-block', + subBlockKey: 'selection', + value: 'saved-selection', + }) + await db.insert(workspaceForkPromoteRun).values({ + id: generateId(), + childWorkspaceId, + sourceWorkspaceId: f.ids[0], + targetWorkspaceId: childWorkspaceId, + direction: 'pull', + snapshot: {}, + }) + } + const edge = { parentWorkspaceId: f.ids[0], childWorkspaceId: f.ids[1] } + expect(await unlinkForkEdge(edge)).toEqual({ unlinked: true }) + for (const table of [ + workspaceForkResourceMap, + workspaceForkBlockMap, + workspaceForkDependentValue, + workspaceForkPromoteRun, + ]) { + const rows = await db + .select({ childWorkspaceId: table.childWorkspaceId }) + .from(table) + .where(inArray(table.childWorkspaceId, [f.ids[1], f.ids[2]])) + expect(rows).toEqual([{ childWorkspaceId: f.ids[2] }]) + } + const first = await db + .select() + .from(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, f.ids)) + const root = first.find((row) => row.workspaceId === f.ids[0]) + const child = first.find((row) => row.workspaceId === f.ids[1]) + const grandchild = first.find((row) => row.workspaceId === f.ids[2]) + if (!root || !child || !grandchild) throw new Error('Missing family membership fixture') + expect(root.projectId).toBe(f.projectId) + expect(child.projectId).not.toBe(f.projectId) + expect(grandchild.projectId).toBe(child.projectId) + expect(await unlinkForkEdge(edge)).toEqual({ unlinked: false }) + const [group] = await db.select().from(permissionGroup).where(eq(permissionGroup.id, groupId)) + expect(group.config).toEqual({ + deniedPartialAccessProjectIssues: [f.projectId, child.projectId], + }) + const second = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, f.ids[1])) + expect(second[0].projectId).toBe(child.projectId) + } + ) + + check( + 'disconnect refuses a partially assigned subtree without moving any environments', + async () => { + const f = await fixture(true, 3) + await db.delete(projectWorkspace).where(eq(projectWorkspace.workspaceId, f.ids[2])) + await expect( + unlinkForkEdge({ parentWorkspaceId: f.ids[0], childWorkspaceId: f.ids[1] }) + ).rejects.toMatchObject({ code: 'conflict' }) + const [child] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, f.ids[1])) + expect(child.projectId).toBe(f.projectId) + const [edge] = await db.select().from(workspace).where(eq(workspace.id, f.ids[1])) + expect(edge.forkedFromWorkspaceId).toBe(f.ids[0]) + } + ) + + check( + 'organization moves remove obsolete Project restrictions while preserving unrelated policy', + async () => { + const source = await fixture(true, 1) + const organizationId = source.organizationId + if (!organizationId) throw new Error('Missing organization fixture') + const destination = await fixture(true, 1) + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: organizationId, + name: 'Source policy', + createdBy: source.ownerId, + isDefault: true, + config: { deniedPartialAccessProjectIssues: [source.projectId], hideTablesTab: true }, + }) + await db.transaction(async (tx) => { + await transferWorkspaceProjects(tx, source.ids, destination.organizationId) + await tx + .update(workspace) + .set({ organizationId: destination.organizationId }) + .where(eq(workspace.id, source.ids[0])) + }) + const [group] = await db.select().from(permissionGroup).where(eq(permissionGroup.id, groupId)) + expect(group.config).toEqual({ deniedPartialAccessProjectIssues: [], hideTablesTab: true }) + const [moved] = await db.select().from(project).where(eq(project.id, source.projectId)) + expect(moved).toMatchObject({ + organizationId: destination.organizationId, + ownerId: source.ownerId, + }) + } + ) + + check( + 'organization deletion preserves Project identity and assigns its former owner explicitly', + async () => { + const f = await fixture() + const organizationId = f.organizationId + if (!organizationId) throw new Error('Missing organization fixture') + await db.transaction(async (tx) => { + await detachOrganizationWorkspacesTx(tx, organizationId) + await tx.delete(organization).where(eq(organization.id, organizationId)) + }) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record).toMatchObject({ id: f.projectId, organizationId: null, ownerId: f.ownerId }) + const result = await getProject.execute({ + principal: f.owner, + input: { projectId: f.projectId }, + request, + }) + expect(result.project.environments).toHaveLength(2) + await expect( + db.transaction((tx) => transferWorkspaceProjects(tx, [f.ids[0]], generateId())) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'account deletion preview reports a surviving Project losing its last active environment', + async () => { + const f = await fixture(false) + await db + .delete(permissions) + .where(and(eq(permissions.entityId, f.ids[0]), eq(permissions.userId, f.teammateId))) + await db + .delete(permissions) + .where(and(eq(permissions.entityId, f.ids[1]), eq(permissions.userId, f.ownerId))) + await db.insert(permissions).values({ + id: generateId(), + entityId: f.ids[1], + entityType: 'workspace', + userId: f.teammateId, + permissionType: 'admin', + }) + await db + .update(workspace) + .set({ archivedAt: new Date(), ownerId: f.teammateId, billedAccountUserId: f.teammateId }) + .where(eq(workspace.id, f.ids[1])) + const plan = await getAccountDeletionPlan(f.ownerId) + expect(plan.workspacesToDelete.map((row) => row.id)).toEqual([f.ids[0]]) + expect(plan.blockers).toEqual([ + { code: 'project_lifecycle', message: expect.stringContaining('Archive') }, + ]) + await expect( + db.transaction((tx) => prepareProjectsForAccountDeletion(tx, f.ownerId, [f.ids[0]])) + ).rejects.toMatchObject({ code: 'conflict' }) + await db.transaction((tx) => archiveProjectInTransaction(tx, f.projectId)) + expect((await getAccountDeletionPlan(f.ownerId)).blockers).toEqual([]) + } + ) + + check( + 'account teardown can choose an organization admin without per-environment grants', + async () => { + const f = await fixture() + if (!f.organizationId) throw new Error('Missing organization fixture') + await db.insert(member).values({ + id: generateId(), + organizationId: f.organizationId, + userId: f.teammateId, + role: 'admin', + createdAt: new Date(), + }) + await db.transaction((tx) => prepareProjectsForAccountDeletion(tx, f.ownerId, [])) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.ownerId).toBe(f.teammateId) + } + ) + + check( + 'account teardown re-reads Projects created by an unlink while waiting for the old Project', + async () => { + const f = await fixture(false) + const held = createDeferred() + const release = createDeferred() + let detachedProjectId: string | null = null + const unlink = db.transaction(async (tx) => { + await lockProject(tx, f.projectId) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + detachedProjectId = await splitForkProject(tx, f.ids[1]) + await tx + .update(workspace) + .set({ forkedFromWorkspaceId: null }) + .where(eq(workspace.id, f.ids[1])) + }) + const blocker = await held.promise + const deletion = db.transaction((tx) => + prepareProjectsForAccountDeletion(tx, f.ownerId, [f.ids[1]]) + ) + try { + let waiting = false + for (let attempt = 0; attempt < 100; attempt++) { + const rows = await db.execute( + sql`SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid))` + ) + if (rows.length) { + waiting = true + break + } + await sleep(10) + } + expect(waiting).toBe(true) + } finally { + release.resolve() + await unlink + } + await deletion + if (!detachedProjectId) throw new Error('Unlink did not create a Project') + expect(await db.select().from(project).where(eq(project.id, detachedProjectId))).toEqual([]) + } + ) + + check( + 'banning a former owner cannot archive environments transferred while waiting for the Project', + async () => { + const f = await fixture(false) + const held = createDeferred() + const release = createDeferred() + const transfer = db.transaction(async (tx) => { + await lockProject(tx, f.projectId) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + await tx + .update(workspace) + .set({ ownerId: f.teammateId }) + .where(inArray(workspace.id, f.ids)) + }) + const blocker = await held.promise + const ban = disableUserResources(f.ownerId) + try { + let waiting = false + for (let attempt = 0; attempt < 100; attempt++) { + const rows = await db.execute( + sql`SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid))` + ) + if (rows.length) { + waiting = true + break + } + await sleep(10) + } + expect(waiting).toBe(true) + } finally { + release.resolve() + await transfer + } + await ban + const rows = await db.select().from(workspace).where(inArray(workspace.id, f.ids)) + expect(rows.every((row) => row.archivedAt === null && row.ownerId === f.teammateId)).toBe( + true + ) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.archivedAt).toBeNull() + } + ) + + check( + 'account teardown transfers surviving Projects and atomically removes wholly private Projects', + async () => { + const f = await fixture(false) + await db.insert(permissions).values({ + id: generateId(), + userId: f.teammateId, + entityType: 'workspace', + entityId: f.ids[1], + permissionType: 'admin', + }) + await db.transaction((tx) => prepareProjectsForAccountDeletion(tx, f.ownerId, [f.ids[0]])) + const [survivor] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(survivor.ownerId).toBe(f.teammateId) + const privateProject = await fixture(false, 1) + await db.transaction((tx) => + prepareProjectsForAccountDeletion(tx, privateProject.ownerId, privateProject.ids) + ) + expect( + await db.select().from(project).where(eq(project.id, privateProject.projectId)) + ).toEqual([]) + } + ) +}) diff --git a/apps/sim/lib/projects/account-deletion.ts b/apps/sim/lib/projects/account-deletion.ts new file mode 100644 index 00000000000..1831df0480d --- /dev/null +++ b/apps/sim/lib/projects/account-deletion.ts @@ -0,0 +1,155 @@ +import { db } from '@sim/db' +import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' +import { and, asc, eq, inArray, ne, or, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' +import { lockProject, lockProjectBackfillWrites } from '@/lib/projects/membership' + +async function loadRelatedProjects(executor: DbOrTx, userId: string, doomedWorkspaceIds: string[]) { + return executor + .select({ id: project.id }) + .from(project) + .where( + or( + eq(project.ownerId, userId), + doomedWorkspaceIds.length + ? sql`${project.id} in ( + select ${projectWorkspace.projectId} from ${projectWorkspace} + where ${inArray(projectWorkspace.workspaceId, doomedWorkspaceIds)} + )` + : undefined + ) + ) + .orderBy(asc(project.id)) +} + +interface ProjectDeletionDecision { + blocker?: string + remove?: boolean + ownerId?: string +} + +async function planProjectDeletion( + executor: DbOrTx, + record: typeof project.$inferSelect, + userId: string, + doomed: Set +): Promise { + const members = await executor + .select({ id: workspace.id, archivedAt: workspace.archivedAt }) + .from(projectWorkspace) + .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) + .where(eq(projectWorkspace.projectId, record.id)) + const survivors = members.filter((row) => !doomed.has(row.id)) + if (!survivors.length) { + return record.organizationId || record.ownerId !== userId + ? { blocker: 'Account deletion cannot remove another owner’s Project' } + : { remove: true } + } + if (!record.archivedAt && survivors.every((row) => row.archivedAt)) { + return { + blocker: 'Archive the Project before deleting its last active environment with your account', + } + } + if (record.ownerId !== userId) return {} + if (record.organizationId) { + const [successor] = await executor + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, record.organizationId), + ne(member.userId, userId), + inArray(member.role, ['owner', 'admin']) + ) + ) + .orderBy(asc(member.userId)) + .limit(1) + if (successor) return { ownerId: successor.userId } + } + const [successor] = await executor + .select({ userId: permissions.userId }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.permissionType, 'admin'), + ne(permissions.userId, userId), + inArray( + permissions.entityId, + survivors.map((row) => row.id) + ) + ) + ) + .groupBy(permissions.userId) + .having(sql`count(*) = ${survivors.length}`) + .orderBy(asc(permissions.userId)) + .limit(1) + return successor + ? { ownerId: successor.userId } + : { + blocker: + 'Give a teammate admin access to every environment before deleting the Project owner’s account', + } +} + +/** Preview uses the same lifecycle and successor rules as the locked deletion transaction. */ +export async function getProjectAccountDeletionBlockers( + userId: string, + doomedWorkspaceIds: string[] +): Promise { + const records = await loadRelatedProjects(db, userId, doomedWorkspaceIds) + const doomed = new Set(doomedWorkspaceIds) + const blockers: string[] = [] + for (const { id } of records) { + const [record] = await db.select().from(project).where(eq(project.id, id)) + if (!record) continue + const decision = await planProjectDeletion(db, record, userId, doomed) + if (decision.blocker) blockers.push(decision.blocker) + } + return blockers +} + +/** Account teardown may erase a wholly private Project, but never strand a surviving one. */ +export async function prepareProjectsForAccountDeletion( + tx: DbTransaction, + userId: string, + doomedWorkspaceIds: string[] +): Promise { + const ownedEnvironments = await tx + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.ownerId, userId)) + await lockProjectBackfillWrites(tx, [ + ...doomedWorkspaceIds, + ...ownedEnvironments.map((row) => row.id), + ]) + const locked = new Set() + for (;;) { + const records = await loadRelatedProjects(tx, userId, doomedWorkspaceIds) + const pending = records.filter((row) => !locked.has(row.id)) + if (!pending.length) break + for (const { id } of pending) { + await lockProject(tx, id) + locked.add(id) + } + } + const records = await loadRelatedProjects(tx, userId, doomedWorkspaceIds) + const doomed = new Set(doomedWorkspaceIds) + for (const { id } of records) { + await lockProject(tx, id) + const [record] = await tx.select().from(project).where(eq(project.id, id)) + if (!record) continue + const decision = await planProjectDeletion(tx, record, userId, doomed) + if (decision.blocker) throw new OrchestrationError('conflict', decision.blocker) + if (decision.remove) { + await tx.delete(projectWorkspace).where(eq(projectWorkspace.projectId, id)) + await tx.delete(project).where(eq(project.id, id)) + } + if (!decision.ownerId) continue + await tx + .update(project) + .set({ ownerId: decision.ownerId, updatedAt: new Date() }) + .where(eq(project.id, id)) + } +} diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts new file mode 100644 index 00000000000..47ebc4f7b43 --- /dev/null +++ b/apps/sim/lib/projects/application/authorization.ts @@ -0,0 +1,128 @@ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, asc, eq, inArray } from 'drizzle-orm' +import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { resolveVerifiedUserAccessControlContext } from '@/lib/permission-groups/resolve.server' +import type { ProjectOperation } from '@/lib/projects/application/operations' +import { lockProject } from '@/lib/projects/membership' + +export function requireProjectPrincipal( + principal: Principal, + operation: Pick +): asserts principal is SessionPrincipal { + if (principal.kind !== 'session') + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) +} + +/** The complete environment set is loaded server-side; hidden environments never enter the result. */ +export async function authorizeProject( + tx: DbTransaction, + principal: SessionPrincipal, + operation: ProjectOperation, + input: { projectId: string; organizationId?: string; workspaceId?: string } +) { + await lockProject(tx, input.projectId) + const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) + if ( + !record || + (input.organizationId !== undefined && input.organizationId !== record.organizationId) + ) { + throw new OrchestrationError('not_found', 'Project not found') + } + const [orgMember] = record.organizationId + ? await tx + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.userId, principal.userId), eq(member.organizationId, record.organizationId)) + ) + .limit(1) + .for('share') + : [] + const orgAdmin = isOrgAdminRole(orgMember?.role) + const environments = await tx + .select({ + id: workspace.id, + name: workspace.name, + organizationId: workspace.organizationId, + archivedAt: workspace.archivedAt, + parentId: workspace.forkedFromWorkspaceId, + }) + .from(projectWorkspace) + .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) + .where(eq(projectWorkspace.projectId, record.id)) + .orderBy(asc(workspace.id)) + if (environments.some((row) => row.organizationId !== record.organizationId)) + throw new OrchestrationError('conflict', 'Project ownership needs reconciliation') + const grants = environments.length + ? await tx + .select({ id: permissions.entityId, permission: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, principal.userId), + inArray( + permissions.entityId, + environments.map((row) => row.id) + ) + ) + ) + .orderBy(asc(permissions.entityId)) + .for('share') + : [] + const grantsById = new Map(grants.map((row) => [row.id, row.permission])) + const rows = environments.map((row) => ({ ...row, permission: grantsById.get(row.id) ?? null })) + if (operation.access === 'issues' && record.organizationId) + await acquirePermissionGroupOrgLock(tx, record.organizationId) + const active = rows.filter((row) => !row.archivedAt) + const visible = active.filter((row) => orgAdmin || row.permission !== null) + const canAdminister = + orgAdmin || (rows.length > 0 && rows.every((row) => row.permission === 'admin')) + if (!visible.length && !(record.archivedAt && canAdminister)) + throw new OrchestrationError('not_found', 'Project not found') + if (input.workspaceId && !visible.some((row) => row.id === input.workspaceId)) + throw new OrchestrationError('not_found', 'Project not found') + if (operation.access === 'admin' && !canAdminister) + throw new OrchestrationError( + 'forbidden', + 'Organization admin or admin access to every environment is required' + ) + let canUseIssues = !record.archivedAt && visible.length > 0 + if (canUseIssues && visible.length < active.length && record.organizationId) { + for (const environment of visible) { + const { config } = await resolveVerifiedUserAccessControlContext( + principal.userId, + environment.id, + record.organizationId, + tx + ) + if (config && CAPABILITY_RULES['project_issues.use'].deniedBy(config, record.id)) { + canUseIssues = false + break + } + } + } + // permission-group-enforced: project_issues.use — Project-wide all-or-nothing gate follows current environment access. + if (operation.access === 'issues' && !canUseIssues) { + if (record.archivedAt) throw new OrchestrationError('conflict', 'Project is archived') + refuseCapability('project_issues.use') + } + const visibleIds = new Set(visible.map((row) => row.id)) + return { + record, + environmentIds: rows.map((row) => row.id), + canAdminister, + canUseIssues, + environments: visible.map((row) => ({ + id: row.id, + name: row.name, + forkedFromWorkspaceId: row.parentId && visibleIds.has(row.parentId) ? row.parentId : null, + })), + } +} diff --git a/apps/sim/lib/projects/application/create-project.ts b/apps/sim/lib/projects/application/create-project.ts new file mode 100644 index 00000000000..95baeca9441 --- /dev/null +++ b/apps/sim/lib/projects/application/create-project.ts @@ -0,0 +1,136 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { refuseCapability } from '@/lib/permission-groups/capabilities' +import { requireProjectPrincipal } from '@/lib/projects/application/authorization' +import { projectOperations } from '@/lib/projects/application/operations' +import { type CreateProjectInput, createProjectInputSchema } from '@/lib/projects/create-input' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' +import { + createWorkspaceWithProjectInTransaction, + emitWorkspaceCreatedPlatformEvent, +} from '@/lib/workspaces/create' +import { + getWorkspaceCreationPolicy, + WorkspaceCreationCapabilityWithheldError, + WorkspaceCreationContextChangedError, +} from '@/lib/workspaces/policy' + +interface CreatedProject { + project: { id: string; name: string } + initialEnvironment: { id: string; name: string } +} + +export const createProject: OperationUseCase< + typeof projectOperations.create, + CreateProjectInput, + CreatedProject +> = { + operation: projectOperations.create, + async execute({ principal, input, request }) { + requireProjectPrincipal(principal, projectOperations.create) + requireProjectApiEnabled() + const parsed = createProjectInputSchema.safeParse(input) + if (!parsed.success) + throw new OrchestrationError( + 'validation', + 'A scope, Project name and initial environment name are required' + ) + const { organizationId, name, initialEnvironment } = parsed.data + const policy = await getWorkspaceCreationPolicy({ + userId: principal.userId, + activeOrganizationId: organizationId, + pinOrganization: true, + }) + if (!policy.canCreate) { + if (policy.blockedReasonCode === 'permission-group-denied') + refuseCapability('workspace.create') + throw new OrchestrationError('forbidden', policy.reason ?? 'Project creation is not allowed') + } + if (policy.organizationId !== organizationId) { + throw new OrchestrationError( + 'forbidden', + 'The requested organization cannot create environments under its current subscription' + ) + } + let created: Awaited> + try { + created = await db.transaction((tx) => + createWorkspaceWithProjectInTransaction(tx, { + userId: principal.userId, + name: initialEnvironment.name, + projectName: name, + organizationId, + workspaceMode: policy.workspaceMode, + billedAccountUserId: policy.billedAccountUserId, + observedOrganizationId: policy.observedOrganizationId, + governingPermissionGroupOrganizationId: policy.governingPermissionGroupOrganizationId, + }) + ) + } catch (error) { + if (error instanceof WorkspaceCreationCapabilityWithheldError) + refuseCapability('workspace.create') + if (error instanceof WorkspaceCreationContextChangedError) + throw new OrchestrationError( + 'conflict', + 'Organization membership changed; retry Project creation' + ) + if (getPostgresErrorCode(error) === '55P03') + throw new OrchestrationError( + 'locked', + 'This organization is being updated; retry Project creation' + ) + if ( + getPostgresErrorCode(error) === '23503' && + getPostgresConstraintName(error) === 'workspace_owner_id_user_id_fk' + ) + throw new OrchestrationError('unauthorized', 'Unauthorized') + if ( + getPostgresErrorCode(error) === '23503' && + getPostgresConstraintName(error) === 'workspace_billed_account_user_id_user_id_fk' + ) + throw new OrchestrationError( + 'conflict', + 'The billing account changed; retry Project creation' + ) + throw error + } + const environment = created.workspace + emitWorkspaceCreatedPlatformEvent({ + workspaceId: environment.id, + userId: principal.userId, + name: environment.name, + }) + recordProjectedUseCaseAuditEntries( + projectOperations.create, + environment.id, + principal, + request, + [ + { + action: AuditAction.PROJECT_CREATED, + resourceType: AuditResourceType.PROJECT, + resourceId: created.projectId, + resourceName: name, + description: `Created Project "${name}"`, + }, + { + action: AuditAction.WORKSPACE_CREATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: environment.id, + resourceName: environment.name, + description: `Created workspace "${environment.name}"`, + metadata: { name: environment.name, workspaceMode: environment.workspaceMode }, + }, + ], + organizationId ?? undefined + ) + return { + project: { id: created.projectId, name }, + initialEnvironment: { id: environment.id, name: environment.name }, + } + }, +} diff --git a/apps/sim/lib/projects/application/index.ts b/apps/sim/lib/projects/application/index.ts new file mode 100644 index 00000000000..2f3d714ebde --- /dev/null +++ b/apps/sim/lib/projects/application/index.ts @@ -0,0 +1,10 @@ +export { createProject } from '@/lib/projects/application/create-project' +export { projectOperations } from '@/lib/projects/application/operations' +export { + archiveProject, + getProject, + getProjectIssueAccess, + getWorkspaceProject, + listProjects, + renameProject, +} from '@/lib/projects/application/use-cases' diff --git a/apps/sim/lib/projects/application/operations.ts b/apps/sim/lib/projects/application/operations.ts new file mode 100644 index 00000000000..4158cd61008 --- /dev/null +++ b/apps/sim/lib/projects/application/operations.ts @@ -0,0 +1,57 @@ +import type { ApplicationOperation } from '@/lib/core/application/operation' +import { assertOperationCapability, defineOperation } from '@/lib/core/application/operation' + +export interface ProjectOperation extends ApplicationOperation { + readonly principalKinds: readonly ['session'] + readonly access: 'read' | 'admin' | 'issues' +} + +/** Project operations cannot borrow authority from workspace keys or an arbitrary root. */ +function defineProjectOperation(operation: O): O { + assertOperationCapability(operation) + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +export const projectOperations = { + create: defineOperation({ + id: 'projects.create', + principalKinds: ['session'], + capability: 'workspace.create', + }), + // permission-group-exempt: navigation exposes only Projects containing accessible environments. + list: defineProjectOperation({ + id: 'projects.list', + principalKinds: ['session'], + access: 'read', + capability: 'none', + }), + // permission-group-exempt: Project metadata does not grant access to its Issues or environments. + get: defineProjectOperation({ + id: 'projects.get', + principalKinds: ['session'], + access: 'read', + capability: 'none', + }), + // permission-group-exempt: Project names are administered by org admins or admins of every environment. + rename: defineProjectOperation({ + id: 'projects.rename', + principalKinds: ['session'], + access: 'admin', + capability: 'none', + }), + // permission-group-exempt: archival revokes execution rather than granting a governed capability. + archive: defineProjectOperation({ + id: 'projects.archive', + principalKinds: ['session'], + access: 'admin', + capability: 'none', + }), + // permission-group-exempt: project_issues.use is parameterized by Project ID and checked after access. + issues: defineProjectOperation({ + id: 'projects.issues.access', + principalKinds: ['session'], + access: 'issues', + capability: 'none', + }), +} as const diff --git a/apps/sim/lib/projects/application/use-cases.ts b/apps/sim/lib/projects/application/use-cases.ts new file mode 100644 index 00000000000..eb7484471d8 --- /dev/null +++ b/apps/sim/lib/projects/application/use-cases.ts @@ -0,0 +1,211 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' +import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizeProject, requireProjectPrincipal } from '@/lib/projects/application/authorization' +import { projectOperations } from '@/lib/projects/application/operations' +import { archiveProjectInTransaction, finishProjectArchive } from '@/lib/projects/lifecycle' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' + +interface ProjectInput { + projectId: string + organizationId?: string + workspaceId?: string +} +type ProjectContext = Awaited> +function presentProject(context: ProjectContext) { + return { + ...context.record, + environments: context.environments, + capabilities: { administer: context.canAdminister, issues: context.canUseIssues }, + } +} + +export const getProject: OperationUseCase< + typeof projectOperations.get, + ProjectInput, + { project: ReturnType } +> = { + operation: projectOperations.get, + async execute({ principal, input }) { + requireProjectPrincipal(principal, projectOperations.get) + requireProjectApiEnabled() + return db.transaction(async (tx) => ({ + project: presentProject(await authorizeProject(tx, principal, projectOperations.get, input)), + })) + }, +} + +/** Read-only capability probe. Issue mutations call authorizeProject inside their own transaction. */ +export const getProjectIssueAccess: OperationUseCase< + typeof projectOperations.issues, + ProjectInput, + { projectId: string } +> = { + operation: projectOperations.issues, + async execute({ principal, input }) { + requireProjectPrincipal(principal, projectOperations.issues) + requireProjectApiEnabled() + return db.transaction(async (tx) => { + const context = await authorizeProject(tx, principal, projectOperations.issues, input) + return { projectId: context.record.id } + }) + }, +} + +export const listProjects: OperationUseCase< + typeof projectOperations.list, + { organizationId?: string; cursor?: string; limit: number }, + { projects: ReturnType[]; nextCursor: string | null } +> = { + operation: projectOperations.list, + async execute({ principal, input }) { + requireProjectPrincipal(principal, projectOperations.list) + requireProjectApiEnabled() + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) + throw new OrchestrationError('validation', 'Limit must be between 1 and 100') + return db.transaction(async (tx) => { + const candidates = await tx + .select({ id: project.id }) + .from(project) + .where( + and( + isNull(project.archivedAt), + input.organizationId ? eq(project.organizationId, input.organizationId) : undefined, + input.cursor ? gt(project.id, input.cursor) : undefined, + sql`exists (select 1 from ${projectWorkspace} pw join ${workspace} w on w.id = pw.workspace_id + where pw.project_id = ${project.id} and w.archived_at is null and ( + exists (select 1 from ${permissions} pe where pe.entity_type = 'workspace' and pe.entity_id = w.id and pe.user_id = ${principal.userId}) + or exists (select 1 from ${member} m where m.organization_id = w.organization_id and m.user_id = ${principal.userId} and m.role in ('owner', 'admin')) + ))` + ) + ) + .orderBy(asc(project.id)) + .limit(input.limit + 1) + const page = candidates.slice(0, input.limit) + const projects = [] + for (const row of page) + projects.push( + presentProject( + await authorizeProject(tx, principal, projectOperations.list, { + projectId: row.id, + organizationId: input.organizationId, + }) + ) + ) + return { + projects, + nextCursor: candidates.length > input.limit ? (page.at(-1)?.id ?? null) : null, + } + }) + }, +} + +export const renameProject: OperationUseCase< + typeof projectOperations.rename, + ProjectInput & { name: string }, + { id: string; name: string } +> = { + operation: projectOperations.rename, + async execute({ principal, input, request }) { + requireProjectPrincipal(principal, projectOperations.rename) + requireProjectApiEnabled() + const name = input.name.trim() + if (!name || name.length > 100) + throw new OrchestrationError('validation', 'Project name must contain 1–100 characters') + const result = await db.transaction(async (tx) => { + const context = await authorizeProject(tx, principal, projectOperations.rename, input) + if (context.record.archivedAt) throw new OrchestrationError('conflict', 'Project is archived') + if (context.record.name === name) return { context, changed: false } + await tx + .update(project) + .set({ name, updatedAt: new Date() }) + .where(eq(project.id, context.record.id)) + return { context, changed: true } + }) + if (result.changed) + recordProjectedUseCaseAuditEntries( + projectOperations.rename, + null, + principal, + request, + [ + { + action: AuditAction.PROJECT_UPDATED, + resourceType: AuditResourceType.PROJECT, + resourceId: input.projectId, + resourceName: name, + }, + ], + result.context.record.organizationId ?? undefined + ) + return { id: input.projectId, name } + }, +} + +export const archiveProject: OperationUseCase< + typeof projectOperations.archive, + ProjectInput, + { id: string; archived: boolean } +> = { + operation: projectOperations.archive, + async execute({ principal, input, request }) { + requireProjectPrincipal(principal, projectOperations.archive) + requireProjectApiEnabled() + const result = await db.transaction(async (tx) => { + const context = await authorizeProject(tx, principal, projectOperations.archive, input) + const effects = await archiveProjectInTransaction(tx, context.record.id) + return { context, effects } + }) + if (!result.context.record.archivedAt) + recordProjectedUseCaseAuditEntries( + projectOperations.archive, + null, + principal, + request, + [ + { + action: AuditAction.PROJECT_ARCHIVED, + resourceType: AuditResourceType.PROJECT, + resourceId: input.projectId, + resourceName: result.context.record.name, + }, + ], + result.context.record.organizationId ?? undefined + ) + await finishProjectArchive(result.effects, `project:${input.projectId}`) + return { id: input.projectId, archived: true } + }, +} + +/** Resolves membership and authorizes the requested environment before returning Project data. */ +export const getWorkspaceProject: OperationUseCase< + typeof projectOperations.get, + { workspaceId: string }, + { project: ReturnType } +> = { + operation: projectOperations.get, + async execute({ principal, input }) { + requireProjectPrincipal(principal, projectOperations.get) + requireProjectApiEnabled() + return db.transaction(async (tx) => { + const [membership] = await tx + .select({ projectId: projectWorkspace.projectId }) + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, input.workspaceId)) + .limit(1) + if (!membership) throw new OrchestrationError('not_found', 'Project not found') + return { + project: presentProject( + await authorizeProject(tx, principal, projectOperations.get, { + projectId: membership.projectId, + workspaceId: input.workspaceId, + }) + ), + } + }) + }, +} diff --git a/apps/sim/lib/projects/create-input.ts b/apps/sim/lib/projects/create-input.ts new file mode 100644 index 00000000000..4eb91f72df2 --- /dev/null +++ b/apps/sim/lib/projects/create-input.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const createProjectInputSchema = z.object({ + organizationId: z.string().trim().min(1).nullable(), + name: z.string().trim().min(1).max(100), + initialEnvironment: z.object({ name: z.string().trim().min(1).max(100) }), +}) +export type CreateProjectInput = z.output diff --git a/apps/sim/lib/projects/lifecycle.ts b/apps/sim/lib/projects/lifecycle.ts new file mode 100644 index 00000000000..d089f7c2e38 --- /dev/null +++ b/apps/sim/lib/projects/lifecycle.ts @@ -0,0 +1,66 @@ +import { + project, + projectWorkspace, + workflow, + workflowMcpServer, + workflowMcpTool, + workspace, +} from '@sim/db/schema' +import { and, asc, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { lockProject } from '@/lib/projects/membership' +import { archiveWorkflowInTransaction, finishWorkflowArchive } from '@/lib/workflows/lifecycle' +import { archiveWorkspaceInTransaction, finishWorkspaceArchive } from '@/lib/workspaces/lifecycle' + +/** All durable archive state commits together; external notifications follow the commit. */ +export async function archiveProjectInTransaction(tx: DbTransaction, projectId: string) { + await lockProject(tx, projectId) + const [record] = await tx.select().from(project).where(eq(project.id, projectId)) + if (!record) throw new OrchestrationError('not_found', 'Project not found') + const now = record.archivedAt ?? new Date() + const members = await tx + .select({ id: projectWorkspace.workspaceId }) + .from(projectWorkspace) + .where(eq(projectWorkspace.projectId, projectId)) + .orderBy(asc(projectWorkspace.workspaceId)) + const workflows: { id: string; workspaceId: string; serverIds: string[] }[] = [] + const environments: { id: string; serverIds: string[] }[] = [] + for (const { id: workspaceId } of members) { + await tx + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .for('update') + const rows = await tx + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) + .orderBy(asc(workflow.id)) + for (const row of rows) { + const servers = await tx + .select({ id: workflowMcpTool.serverId }) + .from(workflowMcpTool) + .where(eq(workflowMcpTool.workflowId, row.id)) + await archiveWorkflowInTransaction(tx, row.id, now) + workflows.push({ id: row.id, workspaceId, serverIds: servers.map((server) => server.id) }) + } + const servers = await tx + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where(eq(workflowMcpServer.workspaceId, workspaceId)) + await archiveWorkspaceInTransaction(tx, workspaceId, now) + environments.push({ id: workspaceId, serverIds: servers.map((server) => server.id) }) + } + await tx.update(project).set({ archivedAt: now, updatedAt: now }).where(eq(project.id, projectId)) + return { workflows, environments } +} + +export async function finishProjectArchive( + effects: Awaited>, + requestId: string +): Promise { + for (const row of effects.workflows) + await finishWorkflowArchive(row.id, row.workspaceId, row.serverIds, { requestId }) + for (const row of effects.environments) await finishWorkspaceArchive(row.id, row.serverIds) +} diff --git a/apps/sim/lib/projects/membership.ts b/apps/sim/lib/projects/membership.ts new file mode 100644 index 00000000000..ed93c8059f0 --- /dev/null +++ b/apps/sim/lib/projects/membership.ts @@ -0,0 +1,296 @@ +import { permissionGroup, project, projectWorkspace, workspace } from '@sim/db/schema' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, inArray, isNull, ne, notInArray, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' + +/** Shared per-environment gate keeps membership absence reads stable during SQL backfill. */ +export async function lockProjectBackfillWrites( + tx: DbTransaction, + workspaceIds: string[] +): Promise { + if (!workspaceIds.length) return + await tx.execute(sql`SET LOCAL lock_timeout = '5s'`) + try { + await tx.execute(sql` + SELECT pg_advisory_xact_lock_shared(hashtextextended('project-backfill:' || id, 0)) + FROM (SELECT DISTINCT unnest(ARRAY[${sql.join( + workspaceIds.map((id) => sql`${id}`), + sql`, ` + )}]::text[]) AS id ORDER BY id) ids + `) + } catch (error) { + if (getPostgresErrorCode(error) === '55P03') + throw new OrchestrationError('conflict', 'Project backfill is running; retry the operation') + throw error + } +} + +/** Canonical Project mutex; membership and lifecycle writers hold it until commit. */ +export async function lockProject(tx: DbTransaction, projectId: string): Promise { + await tx.execute(sql`SELECT set_config('lock_timeout', '5000ms', true)`) + try { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project:${projectId}`}, 0))` + ) + } catch (error) { + if (getPostgresErrorCode(error) === '55P03') + throw new OrchestrationError('conflict', 'Project is changing; retry the operation') + throw error + } +} + +function generatedProjectName(workspaceName: string): string { + const suffix = ' - Project' + return `${(workspaceName.trim() || 'Untitled').slice(0, 100 - suffix.length)}${suffix}` +} + +export async function createProjectForWorkspace( + tx: DbTransaction, + input: { + workspaceId: string + name: string + organizationId: string | null + ownerId: string + archivedAt?: Date | null + projectName?: string + } +): Promise { + const id = generateId() + await tx.insert(project).values({ + id, + name: input.projectName ?? generatedProjectName(input.name), + organizationId: input.organizationId, + ownerId: input.ownerId, + archivedAt: input.archivedAt ?? null, + }) + await tx.insert(projectWorkspace).values({ projectId: id, workspaceId: input.workspaceId }) + return id +} + +/** Returns null only for a legacy workspace awaiting the SQL backfill. */ +export async function lockWorkspaceProject(tx: DbTransaction, workspaceId: string) { + await lockProjectBackfillWrites(tx, [workspaceId]) + const [membership] = await tx + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + .limit(1) + if (!membership) return null + await lockProject(tx, membership.projectId) + const [current] = await tx + .select({ project }) + .from(projectWorkspace) + .innerJoin(project, eq(project.id, projectWorkspace.projectId)) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + .limit(1) + if (!current || current.project.id !== membership.projectId) { + throw new OrchestrationError('conflict', 'Project membership changed; retry the operation') + } + return current.project +} + +export async function requireForkProject(tx: DbTransaction, parentWorkspaceId: string) { + const parent = await lockWorkspaceProject(tx, parentWorkspaceId) + if (!parent) { + await requireUnassignedForkSubtree(tx, parentWorkspaceId) + return null + } + if (parent.archivedAt) throw new OrchestrationError('conflict', 'Cannot fork an archived Project') + return parent +} + +/** Legacy fallback must not hide partially assigned descendants. Caller holds the lineage lock. */ +async function requireUnassignedForkSubtree(tx: DbTransaction, workspaceId: string): Promise { + const descendants = await tx.execute<{ id: string }>(sql` + WITH RECURSIVE descendants AS ( + SELECT id FROM workspace WHERE id = ${workspaceId} + UNION + SELECT w.id FROM workspace w JOIN descendants d ON w.forked_from_workspace_id = d.id + ) SELECT id FROM descendants + `) + if (!descendants.length) return + await lockProjectBackfillWrites( + tx, + descendants.map((row) => row.id) + ) + const rows = await tx + .select({ id: projectWorkspace.workspaceId }) + .from(projectWorkspace) + .where( + inArray( + projectWorkspace.workspaceId, + descendants.map((row) => row.id) + ) + ) + .limit(1) + if (rows.length) + throw new OrchestrationError( + 'conflict', + 'Fork descendants need Project membership reconciliation' + ) +} + +/** Individual removal cannot leave an active Project without an active environment. */ +export async function requireRemainingProjectEnvironment( + tx: DbTransaction, + workspaceId: string +): Promise { + const owner = await lockWorkspaceProject(tx, workspaceId) + if (!owner) return + const [remaining] = await tx + .select({ id: workspace.id }) + .from(projectWorkspace) + .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) + .where( + and( + eq(projectWorkspace.projectId, owner.id), + ne(workspace.id, workspaceId), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + if (!remaining && !owner.archivedAt) { + throw new OrchestrationError( + 'conflict', + 'The last active environment cannot be removed. Archive the Project instead.' + ) + } +} + +/** Called before clearing the edge, under the existing lineage lock. */ +export async function splitForkProject( + tx: DbTransaction, + workspaceId: string +): Promise { + const owner = await lockWorkspaceProject(tx, workspaceId) + if (!owner) { + await requireUnassignedForkSubtree(tx, workspaceId) + return null + } + if (owner.archivedAt) + throw new OrchestrationError('conflict', 'Cannot disconnect an archived Project') + const rows = await tx.execute<{ + id: string + name: string + owner_id: string + archived_at: Date | null + project_id: string | null + }>(sql` + WITH RECURSIVE descendants AS ( + SELECT id, name, owner_id, archived_at FROM workspace WHERE id = ${workspaceId} + UNION + SELECT w.id, w.name, w.owner_id, w.archived_at FROM workspace w JOIN descendants d ON w.forked_from_workspace_id = d.id + ) SELECT d.*, pw.project_id FROM descendants d LEFT JOIN project_workspace pw ON pw.workspace_id = d.id + `) + if (rows.some((row) => row.project_id !== owner.id)) + throw new OrchestrationError( + 'conflict', + 'Fork descendants need Project membership reconciliation before disconnecting' + ) + const root = rows.find((row) => row.id === workspaceId) + if (!root || rows.every((row) => row.archived_at)) + throw new OrchestrationError('conflict', 'A new Project needs an active environment') + const ids = rows.map((row) => row.id) + const [remaining] = await tx + .select({ id: workspace.id }) + .from(projectWorkspace) + .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) + .where( + and( + eq(projectWorkspace.projectId, owner.id), + isNull(workspace.archivedAt), + notInArray(workspace.id, ids) + ) + ) + .limit(1) + if (!remaining) + throw new OrchestrationError( + 'conflict', + 'Disconnecting would remove the last active environment from this Project' + ) + const id = generateId() + await tx.insert(project).values({ + id, + name: generatedProjectName(root.name), + organizationId: owner.organizationId, + ownerId: root.owner_id, + }) + await tx + .update(projectWorkspace) + .set({ projectId: id }) + .where( + and(eq(projectWorkspace.projectId, owner.id), inArray(projectWorkspace.workspaceId, ids)) + ) + if (owner.organizationId) { + await tx.execute(sql` + UPDATE ${permissionGroup} + SET config = jsonb_set(config, '{deniedPartialAccessProjectIssues}', + (config->'deniedPartialAccessProjectIssues') || to_jsonb(${id}::text)), updated_at = now() + WHERE organization_id = ${owner.organizationId} + AND config->'deniedPartialAccessProjectIssues' ? ${owner.id} + `) + } + return id +} + +/** Ownership changes include the complete Project; never silently split Project-wide resources. */ +export async function transferWorkspaceProjects( + tx: DbTransaction, + workspaceIds: string[], + organizationId: string | null, + ownerId?: string +): Promise { + if (!workspaceIds.length) return + await lockProjectBackfillWrites(tx, workspaceIds) + const owners = await tx + .selectDistinct({ id: projectWorkspace.projectId }) + .from(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, workspaceIds)) + .orderBy(asc(projectWorkspace.projectId)) + const selected = new Set(workspaceIds) + for (const owner of owners) { + await tryLockProject(tx, owner.id) + const members = await tx + .select({ id: projectWorkspace.workspaceId }) + .from(projectWorkspace) + .where(eq(projectWorkspace.projectId, owner.id)) + if (members.some((row) => !selected.has(row.id))) { + throw new OrchestrationError( + 'conflict', + 'Move all environments in the Project together, or disconnect the fork first' + ) + } + const [current] = await tx + .select({ organizationId: project.organizationId }) + .from(project) + .where(eq(project.id, owner.id)) + if (current?.organizationId && current.organizationId !== organizationId) { + await tx.execute(sql` + UPDATE ${permissionGroup} + SET config = jsonb_set(config, '{deniedPartialAccessProjectIssues}', + (config->'deniedPartialAccessProjectIssues') - ${owner.id}), updated_at = now() + WHERE organization_id = ${current.organizationId} + AND config->'deniedPartialAccessProjectIssues' ? ${owner.id} + `) + } + await tx + .update(project) + .set({ + organizationId, + ownerId, + updatedAt: new Date(), + }) + .where(eq(project.id, owner.id)) + } +} + +/** Existing ownership paths can hold workspace rows first; refuse contention instead of inverting locks. */ +export async function tryLockProject(tx: DbOrTx, projectId: string): Promise { + const [lock] = await tx.execute<{ acquired: boolean }>( + sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${`project:${projectId}`}, 0)) AS acquired` + ) + if (!lock?.acquired) + throw new OrchestrationError('conflict', 'Project is changing; retry the ownership change') +} diff --git a/apps/sim/lib/projects/rollout.server.ts b/apps/sim/lib/projects/rollout.server.ts new file mode 100644 index 00000000000..1f6e24962e4 --- /dev/null +++ b/apps/sim/lib/projects/rollout.server.ts @@ -0,0 +1,13 @@ +import { envBoolean, getEnv } from '@/lib/core/config/env' +import { HttpError } from '@/lib/core/utils/http-error' + +class ProjectUnavailableError extends HttpError { + readonly statusCode = 503 + constructor() { + super('Projects are not enabled on this deployment') + } +} + +export function requireProjectApiEnabled(): void { + if (!(envBoolean(getEnv('PROJECT_API_ENABLED')) ?? false)) throw new ProjectUnavailableError() +} diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index b9876209500..ce66288f5d4 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -25,6 +25,10 @@ import type { import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' import { isSoleOwnerOfPaidOrganization } from '@/lib/billing/organizations/membership' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getProjectAccountDeletionBlockers, + prepareProjectsForAccountDeletion, +} from '@/lib/projects/account-deletion' import { appendTableEvent, type TableEvent } from '@/lib/table/events' import { type CancelledCellMarker, @@ -322,7 +326,7 @@ export async function getAccountDeletionPlan(userId: string): Promise 0, }) + const projectBlockers = await getProjectAccountDeletionBlockers( + userId, + plan.workspacesToDelete.map((row) => row.id) + ) + plan.blockers.push( + ...projectBlockers.map((message) => ({ code: 'project_lifecycle' as const, message })) + ) + return plan } interface StorageKeyRow { @@ -681,6 +693,7 @@ export async function deleteUserAccount(userId: string): Promise { + await prepareProjectsForAccountDeletion(tx, userId, doomedWorkspaceIds) if (doomedWorkspaceIds.length > 0) { /** * Re-checked here rather than trusted from the plan: a workspace that diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index 965ae9ca62b..67d51ff0095 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -3,6 +3,7 @@ import { apiKey, chat, folder as folderTable, + projectWorkspace, webhook, workflow, workflowDeploymentVersion, @@ -16,10 +17,12 @@ import { env } from '@/lib/core/config/env' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' +import type { DbTransaction } from '@/lib/db/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims' import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations' import { getWorkflowById } from '@/lib/workflows/utils' +import { lockActiveWorkspace } from '@/lib/workspaces/active-workspace' const logger = createLogger('WorkflowLifecycle') @@ -106,87 +109,14 @@ export async function archiveWorkflow( .from(workflowMcpTool) .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) - await db.transaction(async (tx) => { - await supersedeInFlightDeploymentOperations(tx, workflowId) - await releaseWebhookPathClaims(tx, workflowId) - - await tx - .update(workflowSchedule) - .set({ - archivedAt: now, - updatedAt: now, - status: 'disabled', - nextRunAt: null, - lastQueuedAt: null, - }) - .where(and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt))) - - await tx - .update(webhook) - .set({ - archivedAt: now, - updatedAt: now, - isActive: false, - }) - .where(and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt))) - - await tx - .update(chat) - .set({ - archivedAt: now, - updatedAt: now, - isActive: false, - }) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) - - await tx - .update(workflowMcpTool) - .set({ - archivedAt: now, - updatedAt: now, - }) - .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + await db.transaction((tx) => archiveWorkflowInTransaction(tx, workflowId, now)) - await tx - .update(workflowDeploymentVersion) - .set({ - isActive: false, - }) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - - await tx - .update(workflow) - .set({ - archivedAt: now, - updatedAt: now, - isDeployed: false, - isPublicApi: false, - }) - .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) - }) - - try { - PlatformEvents.workflowDeleted({ - workflowId, - workspaceId: existingWorkflow.workspaceId || undefined, - }) - } catch {} - - if (options.notifySocket !== false) { - await notifyWorkflowArchived(workflowId, options.requestId) - } - - await cleanupExternalWebhooksForWorkflow(workflowId, options.requestId) - - if (existingWorkflow.workspaceId && mcpPubSub && affectedWorkflowMcpServers.length > 0) { - const uniqueServerIds = [...new Set(affectedWorkflowMcpServers.map((row) => row.serverId))] - for (const serverId of uniqueServerIds) { - mcpPubSub.publishWorkflowToolsChanged({ - serverId, - workspaceId: existingWorkflow.workspaceId, - }) - } - } + await finishWorkflowArchive( + workflowId, + existingWorkflow.workspaceId, + affectedWorkflowMcpServers.map((row) => row.serverId), + options + ) return { archived: true, @@ -249,6 +179,8 @@ export async function restoreWorkflow( const archivedAt = existingWorkflow.archivedAt await db.transaction(async (tx) => { + if (existingWorkflow.workspaceId) await lockActiveWorkspace(tx, existingWorkflow.workspaceId) + await tx .update(workflow) .set({ @@ -368,12 +300,132 @@ export async function disableUserResources(userId: string): Promise { .from(workspace) .where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt))) - await Promise.all([ - ...ownedWorkspaces.map((w) => archiveWorkspace(w.id, { requestId })), - db.delete(apiKey).where(eq(apiKey.userId, userId)), - ]) + const { archiveProjectInTransaction, finishProjectArchive } = await import( + '@/lib/projects/lifecycle' + ) + const { lockWorkspaceProject } = await import('@/lib/projects/membership') + const processed = new Set() + for (const row of ownedWorkspaces) { + if (processed.has(row.id)) continue + const archived = await db.transaction(async (tx) => { + const record = await lockWorkspaceProject(tx, row.id) + if (!record) return null + const active = await tx + .select({ id: workspace.id, ownerId: workspace.ownerId }) + .from(projectWorkspace) + .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) + .where(and(eq(projectWorkspace.projectId, record.id), isNull(workspace.archivedAt))) + .orderBy(workspace.id) + .for('no key update', { of: workspace }) + if (!active.length || !active.every((entry) => entry.ownerId === userId)) return null + return archiveProjectInTransaction(tx, record.id) + }) + if (archived) { + for (const entry of archived.environments) processed.add(entry.id) + await finishProjectArchive(archived, requestId) + } else { + await archiveWorkspace(row.id, { requestId, expectedOwnerId: userId }) + processed.add(row.id) + } + } + await db.delete(apiKey).where(eq(apiKey.userId, userId)) logger.info( `[${requestId}] Disabled resources for user ${userId}: archived ${ownedWorkspaces.length} workspaces, deleted API keys` ) } + +/** Durable archive state shared by single-workflow and compound Project archival. */ +export async function archiveWorkflowInTransaction( + tx: DbTransaction, + workflowId: string, + now: Date +): Promise { + await supersedeInFlightDeploymentOperations(tx, workflowId) + await releaseWebhookPathClaims(tx, workflowId) + + await tx + .update(workflowSchedule) + .set({ + archivedAt: now, + updatedAt: now, + status: 'disabled', + nextRunAt: null, + lastQueuedAt: null, + }) + .where(and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt))) + + await tx + .update(webhook) + .set({ + archivedAt: now, + updatedAt: now, + isActive: false, + }) + .where(and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt))) + + await tx + .update(chat) + .set({ + archivedAt: now, + updatedAt: now, + isActive: false, + }) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + + await tx + .update(workflowMcpTool) + .set({ + archivedAt: now, + updatedAt: now, + }) + .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + + await tx + .update(workflowDeploymentVersion) + .set({ + isActive: false, + }) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + + await tx + .update(workflow) + .set({ + archivedAt: now, + updatedAt: now, + isDeployed: false, + isPublicApi: false, + }) + .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) +} + +/** Best-effort external notifications run only after durable archive state commits. */ +export async function finishWorkflowArchive( + workflowId: string, + workspaceId: string | null, + serverIds: string[], + options: ArchiveWorkflowOptions +): Promise { + try { + PlatformEvents.workflowDeleted({ + workflowId, + workspaceId: workspaceId || undefined, + }) + } catch {} + + if (options.notifySocket !== false) { + await notifyWorkflowArchived(workflowId, options.requestId) + } + + await cleanupExternalWebhooksForWorkflow(workflowId, options.requestId) + + if (workspaceId && mcpPubSub && serverIds.length > 0) { + const uniqueServerIds = [...new Set(serverIds)] + for (const serverId of uniqueServerIds) { + mcpPubSub.publishWorkflowToolsChanged({ + serverId, + workspaceId: workspaceId, + }) + } + } +} diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts index b8eef76dee3..c5ed40c2550 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts @@ -2,7 +2,9 @@ import { auditMock, dbChainMockFns, posthogServerMock, + queueTableRows, resetDbChainMock, + schemaMock, workflowAuthzMockFns, workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, @@ -45,6 +47,9 @@ const createParams = { describe('performCreateWorkflowTransition unique-violation handling', () => { beforeEach(() => { resetDbChainMock() + queueTableRows(schemaMock.workspace, [ + { archivedAt: null, forkSyncNewWorkflowsExcluded: false }, + ]) workflowAuthzMockFns.mockIsFolderInWorkspace.mockResolvedValue(true) workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true, diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index d2be643a92f..7310f7544a3 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -8,7 +8,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, ne } from 'drizzle-orm' import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' -import type { DbOrTx } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' @@ -182,7 +182,10 @@ async function isWorkflowFolderInWorkspace( } /** Inserts only the workflow row so compound creation can commit its graph and receipt together. */ -export async function createWorkflowInTransaction(tx: DbOrTx, params: PerformCreateWorkflowParams) { +export async function createWorkflowInTransaction( + tx: DbTransaction, + params: PerformCreateWorkflowParams +) { const folderId = params.folderId ?? null if (!(await isWorkflowFolderInWorkspace(folderId, params.workspaceId, tx))) { throw new OrchestrationError('not_found', 'Target folder not found') diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts index 7a6706e9b46..d9a2275bf50 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.test.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -51,6 +51,9 @@ function insertedValuesFor(table: unknown): unknown[] { describe('duplicateWorkflow ordering', () => { beforeEach(() => { resetDbChainMock() + queueTableRows(schemaMock.workspace, [ + { archivedAt: null, forkSyncNewWorkflowsExcluded: false }, + ]) vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('new-workflow-id'), diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 94e90ed829e..09d486204a6 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -18,7 +18,7 @@ import { normalizeWorkflowEdgeTargetHandle, } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' -import type { DbOrTx } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { @@ -51,7 +51,7 @@ interface DuplicateWorkflowOptions { * would require a second pooled connection while the caller's transaction * holds the first. */ - tx?: DbOrTx + tx?: DbTransaction workflowIdMap?: Map } @@ -161,7 +161,7 @@ export async function duplicateWorkflow( } } - const duplicateWithinTransaction = async (tx: DbOrTx) => { + const duplicateWithinTransaction = async (tx: DbTransaction) => { // First verify the source workflow exists const sourceWorkflowRow = await tx .select() diff --git a/apps/sim/lib/workflows/persistence/new-workflow-row.ts b/apps/sim/lib/workflows/persistence/new-workflow-row.ts index e1e10809137..4b7324a8611 100644 --- a/apps/sim/lib/workflows/persistence/new-workflow-row.ts +++ b/apps/sim/lib/workflows/persistence/new-workflow-row.ts @@ -1,6 +1,7 @@ import { type workflow, workspace } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import type { DbOrTx } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' +import { lockActiveWorkspace } from '@/lib/workspaces/active-workspace' interface NewWorkflowRowInput { id: string @@ -39,7 +40,8 @@ export async function readForkSyncNewWorkflowsExcluded( * column default. A fork or promote copy is not new and is written by * `copyWorkflowStateIntoTarget` instead. */ -export async function buildNewWorkflowRow(executor: DbOrTx, input: NewWorkflowRowInput) { +export async function buildNewWorkflowRow(executor: DbTransaction, input: NewWorkflowRowInput) { + const workspace = await lockActiveWorkspace(executor, input.workspaceId) const now = input.now ?? new Date() return { id: input.id, @@ -55,6 +57,6 @@ export async function buildNewWorkflowRow(executor: DbOrTx, input: NewWorkflowRo isDeployed: false, runCount: 0, variables: input.variables ?? {}, - forkSyncExcluded: await readForkSyncNewWorkflowsExcluded(executor, input.workspaceId), + forkSyncExcluded: workspace.forkSyncNewWorkflowsExcluded, } satisfies typeof workflow.$inferInsert } diff --git a/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts index a84026527d4..52a1f1b5c4d 100644 --- a/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts +++ b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts @@ -9,6 +9,8 @@ import { knowledgeBase, outboxEvent, permissions, + project, + projectWorkspace, user, userTableDefinitions, workflow, @@ -28,6 +30,7 @@ import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-i import { processOutboxEventById } from '@/lib/core/outbox/service' import * as workflowMcpSync from '@/lib/mcp/workflow-mcp-sync' import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' +import { createProjectForWorkspace } from '@/lib/projects/membership' import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' import { @@ -151,6 +154,14 @@ describe('authorized fork and sync against PostgreSQL', () => { billedAccountUserId: userId, allowPersonalApiKeys: true, }) + await db.transaction((tx) => + createProjectForWorkspace(tx, { + workspaceId: sourceWorkspaceId, + name: 'Fork source fixture', + ownerId: userId, + organizationId: null, + }) + ) await db.insert(permissions).values({ id: generateId(), userId, @@ -183,6 +194,19 @@ describe('authorized fork and sync against PostgreSQL', () => { }) }) afterAll(async () => { + const owned = await db + .select({ id: project.id }) + .from(project) + .where(eq(project.ownerId, userId)) + if (owned.length) { + await db.delete(projectWorkspace).where( + inArray( + projectWorkspace.projectId, + owned.map((row) => row.id) + ) + ) + await db.delete(project).where(eq(project.ownerId, userId)) + } for (const id of createdWorkspaceIds) await db.delete(workspace).where(eq(workspace.id, id)) await db.delete(workspace).where(eq(workspace.id, sourceWorkspaceId)) await db.delete(user).where(eq(user.id, userId)) diff --git a/apps/sim/lib/workspaces/active-workspace.ts b/apps/sim/lib/workspaces/active-workspace.ts new file mode 100644 index 00000000000..f506aa02ae8 --- /dev/null +++ b/apps/sim/lib/workspaces/active-workspace.ts @@ -0,0 +1,19 @@ +import { workspace } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' + +/** Transactional resource creation holds this row through insertion so archival cannot overtake it. */ +export async function lockActiveWorkspace(executor: DbOrTx, workspaceId: string) { + const [record] = await executor + .select({ + archivedAt: workspace.archivedAt, + forkSyncNewWorkflowsExcluded: workspace.forkSyncNewWorkflowsExcluded, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .for('share') + .limit(1) + if (!record || record.archivedAt) throw new OrchestrationError('not_found', 'Workspace not found') + return record +} diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index 1571faf8af3..9ad81035fd1 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -30,6 +30,7 @@ import { planHasFixedSeatCap, resolveSeatCapacity, } from '@/lib/billing/validation/seat-management' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { addOutboxEventSourceOperationId, enqueueOrReschedulePendingOutboxEvent, @@ -41,6 +42,7 @@ import type { DbOrTx } from '@/lib/db/types' import { getInvitationById, isInvitationExpired } from '@/lib/invitations/core' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { PENDING_INVITATION_UNIQUE_INDEX, sendInvitationEmail } from '@/lib/invitations/send' +import { transferWorkspaceProjects } from '@/lib/projects/membership' import { invalidateWorkspaceTableLimitsCache } from '@/lib/table/billing' import { deleteCustomBlock } from '@/lib/workflows/custom-blocks/operations' import { @@ -116,6 +118,7 @@ export class WorkspaceMoveError extends Error { | 'destination-entitlement-downgrade' | 'fork-lineage-conflict' | 'pending-invitations-present' + | 'project-conflict' ) { super(message) this.name = 'WorkspaceMoveError' @@ -1356,6 +1359,8 @@ export async function moveWorkspaceToOrganization(params: { }) : { detachedPermissionGroupIds: [] } + await transferWorkspaceProjects(tx, [params.workspaceId], params.destinationOrganizationId) + await changeWorkspaceStoragePayerInTx(tx, { workspaceId: params.workspaceId, organizationId: params.destinationOrganizationId, @@ -1496,6 +1501,9 @@ export async function moveWorkspaceToOrganization(params: { }) break } catch (error) { + if (error instanceof OrchestrationError && error.code === 'conflict') { + throw new WorkspaceMoveError(error.message, 'project-conflict') + } if (error instanceof InvitationSetChangedError) { candidateInvitationIds = error.invitationIds continue diff --git a/apps/sim/lib/workspaces/create.test.ts b/apps/sim/lib/workspaces/create.test.ts index fc42089f009..680a6fd1883 100644 --- a/apps/sim/lib/workspaces/create.test.ts +++ b/apps/sim/lib/workspaces/create.test.ts @@ -1,6 +1,8 @@ +import { workspace } from '@sim/db/schema' import { dbChainMock, dbChainMockFns, + queueTableRows, resetDbChainMock, workflowsPersistenceUtilsMock, } from '@sim/testing' @@ -122,6 +124,7 @@ describe('createDefaultPersonalWorkspaceInTransaction', () => { */ it('creates an ungoverned personal workspace and resolves no regime', async () => { mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'user-1' }) + queueTableRows(workspace, [{ archivedAt: null, forkSyncNewWorkflowsExcluded: false }]) const tx = dbChainMock.db as unknown as DbOrTx await createDefaultPersonalWorkspaceInTransaction(tx, { diff --git a/apps/sim/lib/workspaces/create.ts b/apps/sim/lib/workspaces/create.ts index 8abfb8d2c99..4b1549bd0ae 100644 --- a/apps/sim/lib/workspaces/create.ts +++ b/apps/sim/lib/workspaces/create.ts @@ -5,6 +5,8 @@ import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/erro import { generateId } from '@sim/utils/id' import { PlatformEvents } from '@/lib/core/telemetry' import type { DbTransaction } from '@/lib/db/types' +import { createProjectForWorkspace } from '@/lib/projects/membership' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -88,9 +90,10 @@ export interface TransactionalCreateWorkspaceParams extends CreateWorkspaceParam * permission-group advisory lock — before inserting the workspace, owner * permission and optional starter workflow atomically. */ -export async function createWorkspaceInTransaction( +async function createWorkspaceRecordsInTransaction( tx: DbTransaction, { + projectName, userId, observedOrganizationId, name, @@ -99,8 +102,8 @@ export async function createWorkspaceInTransaction( workspaceMode, billedAccountUserId, governingPermissionGroupOrganizationId, - }: TransactionalCreateWorkspaceParams -): Promise { + }: TransactionalCreateWorkspaceParams & { projectName?: string } +): Promise<{ projectId: string; workspace: CreatedWorkspace }> { const workspaceId = generateId() const workflowId = generateId() const now = new Date() @@ -129,6 +132,14 @@ export async function createWorkspaceInTransaction( updatedAt: now, }) + const projectId = await createProjectForWorkspace(tx, { + projectName, + workspaceId, + name, + organizationId: organizationId ?? null, + ownerId: userId, + }) + const permissionRows = [ { id: generateId(), @@ -178,18 +189,38 @@ export async function createWorkspaceInTransaction( } return { - id: workspaceId, - name, - ownerId: userId, - organizationId, - workspaceMode, - billedAccountUserId: committedBilledAccountUserId, - allowPersonalApiKeys: true, - createdAt: now, - updatedAt: now, + projectId, + workspace: { + id: workspaceId, + name, + ownerId: userId, + organizationId, + workspaceMode, + billedAccountUserId: committedBilledAccountUserId, + allowPersonalApiKeys: true, + createdAt: now, + updatedAt: now, + }, } } +/** Explicit Project creation always commits its first environment in the same transaction. */ +export async function createWorkspaceWithProjectInTransaction( + tx: DbTransaction, + params: TransactionalCreateWorkspaceParams & { projectName?: string } +): Promise<{ projectId: string; workspace: CreatedWorkspace }> { + requireProjectApiEnabled() + return createWorkspaceRecordsInTransaction(tx, params) +} + +/** Preserves the workspace-only result for existing creation callers. */ +export async function createWorkspaceInTransaction( + tx: DbTransaction, + params: TransactionalCreateWorkspaceParams +): Promise { + return (await createWorkspaceRecordsInTransaction(tx, params)).workspace +} + /** Creates a workspace through the canonical lock-and-insert transaction. */ export async function createWorkspace(params: CreateWorkspaceParams) { /** diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts deleted file mode 100644 index 5d447ae689b..00000000000 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - dbChainMockFns, - permissionsMock, - permissionsMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockArchiveWorkflowsForWorkspace } = vi.hoisted(() => ({ - mockArchiveWorkflowsForWorkspace: vi.fn(), -})) - -const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner - -vi.mock('@/lib/workflows/lifecycle', () => ({ - archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args), -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -import { archiveWorkspace } from '@/lib/workspaces/lifecycle' - -function createUpdateChain() { - return { - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } -} - -describe('workspace lifecycle', () => { - beforeEach(() => { - resetDbChainMock() - }) - - afterAll(() => { - resetDbChainMock() - }) - - it('archives workspace and dependent resources', async () => { - mockGetWorkspaceWithOwner.mockResolvedValue({ - id: 'workspace-1', - name: 'Workspace 1', - ownerId: 'user-1', - archivedAt: null, - }) - mockArchiveWorkflowsForWorkspace.mockResolvedValue(2) - queueTableRows(schemaMock.workflowMcpServer, [{ id: 'server-1' }]) - - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]), - }), - }), - update: vi.fn().mockImplementation(() => createUpdateChain()), - delete: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - } - dbChainMockFns.transaction.mockImplementation( - async (callback: (trx: typeof tx) => Promise) => callback(tx) - ) - - const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' }) - - expect(result).toEqual({ - archived: true, - workspaceName: 'Workspace 1', - }) - expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', { - requestId: 'req-1', - }) - expect(tx.update).toHaveBeenCalledTimes(9) - expect(tx.delete).toHaveBeenCalledTimes(1) - }) - - it('retries child cleanup for already archived workspaces', async () => { - mockGetWorkspaceWithOwner.mockResolvedValue({ - id: 'workspace-1', - name: 'Workspace 1', - ownerId: 'user-1', - archivedAt: new Date(), - }) - - const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' }) - - expect(result).toEqual({ - archived: false, - workspaceName: 'Workspace 1', - }) - expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', { - requestId: 'req-1', - }) - expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index e66f5465644..08fa60e21c9 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -14,8 +14,10 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import type { DbTransaction } from '@/lib/db/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { mcpService } from '@/lib/mcp/service' +import { lockWorkspaceProject, requireRemainingProjectEnvironment } from '@/lib/projects/membership' import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -23,6 +25,7 @@ const logger = createLogger('WorkspaceLifecycle') interface ArchiveWorkspaceOptions { requestId: string + expectedOwnerId?: string } export async function archiveWorkspace( @@ -42,130 +45,157 @@ export async function archiveWorkspace( .from(workflowMcpServer) .where(eq(workflowMcpServer.workspaceId, workspaceId)) - await db.transaction(async (tx) => { - await tx - .update(knowledgeBase) - .set({ - deletedAt: now, - updatedAt: now, - }) - .where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))) - - const workspaceKbIds = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(eq(knowledgeBase.workspaceId, workspaceId)) - - const knowledgeBaseIds = workspaceKbIds.map((entry) => entry.id) - if (knowledgeBaseIds.length > 0) { - await tx - .update(document) - .set({ archivedAt: now }) - .where( - and( - inArray(document.knowledgeBaseId, knowledgeBaseIds), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - - await tx - .update(knowledgeConnector) - .set({ archivedAt: now, status: 'paused', updatedAt: now }) - .where( - and( - inArray(knowledgeConnector.knowledgeBaseId, knowledgeBaseIds), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) + const archived = await db.transaction(async (tx) => { + if (options.expectedOwnerId) { + await lockWorkspaceProject(tx, workspaceId) + const [current] = await tx + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .for('no key update') + if (!current || current.ownerId !== options.expectedOwnerId) return false } + await requireRemainingProjectEnvironment(tx, workspaceId) + await archiveWorkspaceInTransaction(tx, workspaceId, now) + return true + }) + if (!archived) return { archived: false } + + await archiveWorkflowsForWorkspace(workspaceId, options) + + logger.info(`[${options.requestId}] Archived workspace ${workspaceId}`) + + await finishWorkspaceArchive( + workspaceId, + workflowMcpServerIds.map((server) => server.id) + ) + + return { + archived: !workspaceRecord.archivedAt, + workspaceName: workspaceRecord.name, + } +} +/** Durable environment archive changes; callers own Project minimum-environment checks. */ +export async function archiveWorkspaceInTransaction( + tx: DbTransaction, + workspaceId: string, + now: Date +): Promise { + await tx + .update(knowledgeBase) + .set({ + deletedAt: now, + updatedAt: now, + }) + .where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))) + + const workspaceKbIds = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(eq(knowledgeBase.workspaceId, workspaceId)) + + const knowledgeBaseIds = workspaceKbIds.map((entry) => entry.id) + if (knowledgeBaseIds.length > 0) { await tx - .update(userTableDefinitions) - .set({ - archivedAt: now, - updatedAt: now, - }) + .update(document) + .set({ archivedAt: now }) .where( and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) + inArray(document.knowledgeBaseId, knowledgeBaseIds), + isNull(document.archivedAt), + isNull(document.deletedAt) ) ) await tx - .update(workspaceFiles) - .set({ - deletedAt: now, - }) - .where(and(eq(workspaceFiles.workspaceId, workspaceId), isNull(workspaceFiles.deletedAt))) - - await tx - .update(invitation) - .set({ - status: 'cancelled', - updatedAt: now, - }) + .update(knowledgeConnector) + .set({ archivedAt: now, status: 'paused', updatedAt: now }) .where( and( - eq(invitation.status, 'pending'), - sql`${invitation.id} IN ( + inArray(knowledgeConnector.knowledgeBaseId, knowledgeBaseIds), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + } + + await tx + .update(userTableDefinitions) + .set({ + archivedAt: now, + updatedAt: now, + }) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + + await tx + .update(workspaceFiles) + .set({ + deletedAt: now, + }) + .where(and(eq(workspaceFiles.workspaceId, workspaceId), isNull(workspaceFiles.deletedAt))) + + await tx + .update(invitation) + .set({ + status: 'cancelled', + updatedAt: now, + }) + .where( + and( + eq(invitation.status, 'pending'), + sql`${invitation.id} IN ( SELECT ${invitationWorkspaceGrant.invitationId} FROM ${invitationWorkspaceGrant} WHERE ${invitationWorkspaceGrant.workspaceId} = ${workspaceId} )` - ) ) + ) + + await tx + .delete(apiKey) + .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) + + await tx + .update(workflowMcpServer) + .set({ + deletedAt: now, + isPublic: false, + updatedAt: now, + }) + .where(eq(workflowMcpServer.workspaceId, workspaceId)) - await tx - .delete(apiKey) - .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) - - await tx - .update(workflowMcpServer) - .set({ - deletedAt: now, - isPublic: false, - updatedAt: now, - }) - .where(eq(workflowMcpServer.workspaceId, workspaceId)) - - await tx - .update(mcpServers) - .set({ - deletedAt: now, - enabled: false, - updatedAt: now, - }) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - - await tx - .update(workspace) - .set({ - archivedAt: now, - updatedAt: now, - }) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - }) - - await archiveWorkflowsForWorkspace(workspaceId, options) - - logger.info(`[${options.requestId}] Archived workspace ${workspaceId}`) + await tx + .update(mcpServers) + .set({ + deletedAt: now, + enabled: false, + updatedAt: now, + }) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + + await tx + .update(workspace) + .set({ + archivedAt: now, + updatedAt: now, + }) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) +} +/** Refreshes derived MCP state after the archive transaction commits. */ +export async function finishWorkspaceArchive( + workspaceId: string, + serverIds: string[] +): Promise { await mcpService.clearCache(workspaceId).catch(() => undefined) - - if (mcpPubSub && workflowMcpServerIds.length > 0) { - for (const server of workflowMcpServerIds) { - mcpPubSub.publishWorkflowToolsChanged({ - serverId: server.id, - workspaceId, - }) - } - } - - return { - archived: !workspaceRecord.archivedAt, - workspaceName: workspaceRecord.name, + if (mcpPubSub) { + for (const serverId of serverIds) + mcpPubSub.publishWorkflowToolsChanged({ serverId, workspaceId }) } } diff --git a/apps/sim/lib/workspaces/organization-workspaces.integration.ts b/apps/sim/lib/workspaces/organization-workspaces.integration.ts index b0fd0397d0a..331b66d33b8 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.integration.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.integration.ts @@ -26,6 +26,7 @@ const database = drizzle(connection, { schema }) beforeAll(async () => { await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) await connection.unsafe(` + CREATE TABLE project_workspace (project_id text NOT NULL, workspace_id text UNIQUE NOT NULL); CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text, role text); CREATE TABLE organization (id text PRIMARY KEY, storage_used_bytes bigint NOT NULL); CREATE TABLE invitation ( diff --git a/apps/sim/lib/workspaces/organization-workspaces.ts b/apps/sim/lib/workspaces/organization-workspaces.ts index 8659ab20424..2db7b69675d 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.ts @@ -14,6 +14,7 @@ import { changeWorkspaceStoragePayersInTx } from '@/lib/billing/storage/payer-tr import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { transferWorkspaceProjects } from '@/lib/projects/membership' import { invalidateWorkspaceTableLimitsCache } from '@/lib/table/billing' import { getOrganizationOwnerId, WORKSPACE_MODE } from '@/lib/workspaces/policy' @@ -351,6 +352,7 @@ export async function attachOwnedWorkspacesToOrganizationTx( } } + await transferWorkspaceProjects(tx, ownedWorkspaceIds, organizationId) const now = new Date() await changeWorkspaceStoragePayersInTx( tx, @@ -492,6 +494,7 @@ export async function detachOrganizationWorkspacesTx( const workspaceIds = organizationWorkspaces .map((organizationWorkspace) => organizationWorkspace.id) .sort() + await transferWorkspaceProjects(tx, workspaceIds, null, organizationOwnerId ?? undefined) await lockWorkspaceRowsForPayerChanges(tx, workspaceIds) const payerChanges = organizationWorkspaces.map((organizationWorkspace) => ({ workspaceId: organizationWorkspace.id, diff --git a/knip.jsonc b/knip.jsonc index 68c63368a0e..e68838874c9 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -45,6 +45,8 @@ // Generated contracts mirror their source of truth; regenerating them must not // trip the unused-export ratchet, and hand edits would be overwritten. "lib/mothership/generated/**": ["exports", "types", "duplicates"], + // API conventions require exported named wire schemas and aliases, including before client adoption. + "lib/api/contracts/projects.ts": ["exports", "types"], "sandbox-tasks/index.ts": ["files"], "components/mcp/index.ts": ["files"], "triggers/quickbooks/index.ts": ["files"], diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 7cd5acb84d4..06547294cf5 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -218,6 +218,9 @@ export const AuditAction = { WORKFLOW_EXPORTED: 'workflow.exported', WORKSPACE_CREATED: 'workspace.created', + PROJECT_CREATED: 'project.created', + PROJECT_UPDATED: 'project.updated', + PROJECT_ARCHIVED: 'project.archived', WORKSPACE_UPDATED: 'workspace.updated', WORKSPACE_DELETED: 'workspace.deleted', WORKSPACE_DUPLICATED: 'workspace.duplicated', @@ -287,6 +290,7 @@ export const AuditResourceType = { USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', + PROJECT: 'project', WORKSPACE: 'workspace', } as const diff --git a/packages/db/migrations/0394_project_foundation.sql b/packages/db/migrations/0394_project_foundation.sql new file mode 100644 index 00000000000..5a50b354418 --- /dev/null +++ b/packages/db/migrations/0394_project_foundation.sql @@ -0,0 +1,25 @@ +CREATE TABLE "project" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "organization_id" text, + "owner_id" text NOT NULL, + "archived_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "project_name_length" CHECK (char_length(btrim("project"."name")) BETWEEN 1 AND 100) +); +--> statement-breakpoint +CREATE TABLE "project_workspace" ( + "project_id" text NOT NULL, + "workspace_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "project_workspace_project_id_workspace_id_pk" PRIMARY KEY("project_id","workspace_id") +); +--> statement-breakpoint +ALTER TABLE "project" ADD CONSTRAINT "project_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project" ADD CONSTRAINT "project_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_workspace" ADD CONSTRAINT "project_workspace_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_workspace" ADD CONSTRAINT "project_workspace_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "project_organization_archive_id_idx" ON "project" USING btree ("organization_id","archived_at","id");--> statement-breakpoint +CREATE INDEX "project_owner_archive_id_idx" ON "project" USING btree ("owner_id","archived_at","id");--> statement-breakpoint +CREATE UNIQUE INDEX "project_workspace_workspace_id_unique" ON "project_workspace" USING btree ("workspace_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0394_snapshot.json b/packages/db/migrations/meta/0394_snapshot.json new file mode 100644 index 00000000000..7792f4f6c31 --- /dev/null +++ b/packages/db/migrations/meta/0394_snapshot.json @@ -0,0 +1,29944 @@ +{ + "id": "c95b8d67-0668-4a2d-9498-50eee5842244", + "prevId": "a361a506-c9dd-476f-8038-fd04f132fbe8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_memory_turn": { + "name": "agent_memory_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_order": { + "name": "execution_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "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()" + } + }, + "indexes": { + "agent_memory_turn_invocation_unique": { + "name": "agent_memory_turn_invocation_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memory_turn_workflow_idx": { + "name": "agent_memory_turn_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_memory_turn_memory_id_memory_id_fk": { + "name": "agent_memory_turn_memory_id_memory_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memory_turn_workflow_id_workflow_id_fk": { + "name": "agent_memory_turn_workflow_id_workflow_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "browser_download_started_at": { + "name": "browser_download_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_started_at": { + "name": "execution_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_settled_at": { + "name": "execution_settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_owner_token": { + "name": "execution_owner_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_lease_expires_at": { + "name": "execution_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_revoked_at": { + "name": "execution_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_workflow_execution_id": { + "name": "client_workflow_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_processes": { + "name": "sandbox_processes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_key": { + "name": "external_conversation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_metadata": { + "name": "external_conversation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_external_conversation_unique": { + "name": "copilot_chats_external_conversation_unique", + "columns": [ + { + "expression": "external_conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_chats\".\"external_conversation_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_organization_request_stops": { + "name": "copilot_organization_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_organization_request_stops_user_id_user_id_fk": { + "name": "copilot_organization_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_organization_request_stops_organization_id_organization_id_fk": { + "name": "copilot_organization_request_stops_organization_id_organization_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_organization_request_stops_user_id_organization_id_stream_id_pk": { + "name": "copilot_organization_request_stops_user_id_organization_id_stream_id_pk", + "columns": ["user_id", "organization_id", "stream_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_request_stops": { + "name": "copilot_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_request_stops_user_id_user_id_fk": { + "name": "copilot_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_request_stops_workspace_id_workspace_id_fk": { + "name": "copilot_request_stops_workspace_id_workspace_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_request_stops_user_id_workspace_id_stream_id_pk": { + "name": "copilot_request_stops_user_id_workspace_id_stream_id_pk", + "columns": ["user_id", "workspace_id", "stream_id"] + } + }, + "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 + }, + "tool_execution_version": { + "name": "tool_execution_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_admission_closed_at": { + "name": "tool_admission_closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_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_chat_started_at_idx": { + "name": "copilot_runs_chat_started_at_idx", + "columns": [ + { + "expression": "chat_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_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" + }, + "copilot_runs_organization_id_organization_id_fk": { + "name": "copilot_runs_organization_id_organization_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_service_usage": { + "name": "copilot_service_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 8)", + "primaryKey": false, + "notNull": false + }, + "worker_origin": { + "name": "worker_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_service_usage_pending_idx": { + "name": "copilot_service_usage_pending_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "delivered_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_task_subscriptions": { + "name": "copilot_task_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_task_subscriptions_execution_idx": { + "name": "copilot_task_subscriptions_execution_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_task_subscriptions_task_idx": { + "name": "copilot_task_subscriptions_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_task_subscriptions_chat_id_copilot_chats_id_fk": { + "name": "copilot_task_subscriptions_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_task_subscriptions", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_task_subscriptions_workspace_id_workspace_id_fk": { + "name": "copilot_task_subscriptions_workspace_id_workspace_id_fk", + "tableFrom": "copilot_task_subscriptions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_task_subscriptions_user_id_user_id_fk": { + "name": "copilot_task_subscriptions_user_id_user_id_fk", + "tableFrom": "copilot_task_subscriptions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_slack_app_id_slack_app_id_fk": { + "name": "credential_slack_app_id_slack_app_id_fk", + "tableFrom": "credential", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard": { + "name": "dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dashboard_workspace_id_unique": { + "name": "dashboard_workspace_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboard_workspace_id_workspace_id_fk": { + "name": "dashboard_workspace_id_workspace_id_fk", + "tableFrom": "dashboard", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_created_by_user_id_fk": { + "name": "dashboard_created_by_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "dashboard_updated_by_user_id_fk": { + "name": "dashboard_updated_by_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_recovery_after": { + "name": "processing_recovery_after", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_source_modified_idx": { + "name": "doc_kb_source_modified_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_recovery_idx": { + "name": "doc_processing_recovery_idx", + "columns": [ + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_processing_recovery_idx": { + "name": "doc_connector_processing_recovery_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_connector_processing_status_idx": { + "name": "doc_connector_processing_status_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_idx": { + "name": "doc_connector_reconciliation_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"source_seen_at\", '-infinity'::timestamp)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_v2_idx": { + "name": "doc_connector_reconciliation_v2_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_tombstone_idx": { + "name": "doc_connector_tombstone_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NULL AND (\"document\".\"deleted_at\" IS NOT NULL OR \"document\".\"content_hash\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_live_idx": { + "name": "doc_connector_live_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_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": {} + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_keyword_search": { + "name": "embedding_keyword_search", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "embedding_keyword_search_kb_idx": { + "name": "embedding_keyword_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_keyword_search_document_idx": { + "name": "embedding_keyword_search_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_keyword_search_content_idx": { + "name": "embedding_keyword_search_content_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_keyword_search_id_embedding_id_fk": { + "name": "embedding_keyword_search_id_embedding_id_fk", + "tableFrom": "embedding_keyword_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding_keyword_tin": { + "name": "embedding_keyword_tin", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "embedding_keyword_tin_document_idx": { + "name": "embedding_keyword_tin_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "embedding_keyword_tin_id_embedding_id_fk": { + "name": "embedding_keyword_tin_id_embedding_id_fk", + "tableFrom": "embedding_keyword_tin", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding_search": { + "name": "embedding_search", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "binary": { + "name": "binary", + "type": "bit(1536)", + "primaryKey": false, + "notNull": false + }, + "binary_384": { + "name": "binary_384", + "type": "bit(384)", + "primaryKey": false, + "notNull": false + }, + "binary_768": { + "name": "binary_768", + "type": "bit(768)", + "primaryKey": false, + "notNull": false + }, + "binary_1024": { + "name": "binary_1024", + "type": "bit(1024)", + "primaryKey": false, + "notNull": false + }, + "binary_3072": { + "name": "binary_3072", + "type": "bit(3072)", + "primaryKey": false, + "notNull": false + }, + "vector": { + "name": "vector", + "type": "halfvec(1536)", + "primaryKey": false, + "notNull": false + }, + "vector_384": { + "name": "vector_384", + "type": "halfvec(384)", + "primaryKey": false, + "notNull": false + }, + "vector_512": { + "name": "vector_512", + "type": "halfvec(512)", + "primaryKey": false, + "notNull": false + }, + "vector_768": { + "name": "vector_768", + "type": "halfvec(768)", + "primaryKey": false, + "notNull": false + }, + "vector_1024": { + "name": "vector_1024", + "type": "halfvec(1024)", + "primaryKey": false, + "notNull": false + }, + "vector_3072": { + "name": "vector_3072", + "type": "halfvec(3072)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "embedding_search_kb_idx": { + "name": "embedding_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_search_document_lookup_idx": { + "name": "embedding_search_document_lookup_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"embedding_search\".\"enabled\"", + "concurrently": true, + "method": "btree", + "with": {} + }, + "embedding_search_cosine_hnsw_idx": { + "name": "embedding_search_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_512_cosine_hnsw_idx": { + "name": "embedding_search_512_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_512", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_384_cosine_hnsw_idx": { + "name": "embedding_search_384_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_768_cosine_hnsw_idx": { + "name": "embedding_search_768_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_1024_cosine_hnsw_idx": { + "name": "embedding_search_1024_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_3072_cosine_hnsw_idx": { + "name": "embedding_search_3072_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_3072", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": { + "embedding_search_id_embedding_id_fk": { + "name": "embedding_search_id_embedding_id_fk", + "tableFrom": "embedding_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_search_width_check": { + "name": "embedding_search_width_check", + "value": "num_nonnulls(\"binary\", \"binary_384\", \"binary_768\", \"binary_1024\", \"binary_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") = 1" + }, + "kb_organization_search_index_check": { + "name": "kb_organization_search_index_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"is_search_index\"" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "member_tombstone_cursor": { + "name": "member_tombstone_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "member_resurrection_cursor": { + "name": "member_resurrection_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detached_at": { + "name": "detached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detach_reserved_bytes": { + "name": "detach_reserved_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewed_at": { + "name": "scope_renewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_cursor": { + "name": "scope_renewal_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_started_at": { + "name": "scope_renewal_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "processing_dispatch_failed": { + "name": "processing_dispatch_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_renewed": { + "name": "observations_renewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + }, + "kcmsl_database_failure_class_check": { + "name": "kcmsl_database_failure_class_check", + "value": "\"knowledge_connector_member_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_partition": { + "name": "knowledge_connector_partition", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "partition_key": { + "name": "partition_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_served_at": { + "name": "last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure": { + "name": "failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "permission_cursor": { + "name": "permission_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_attempts": { + "name": "permission_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permission_retry_at": { + "name": "permission_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permission_last_served_at": { + "name": "permission_last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_started_at": { + "name": "permission_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_failure": { + "name": "permission_failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcp_content_due_idx": { + "name": "kcp_content_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcp_permission_due_idx": { + "name": "kcp_permission_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_partition_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_partition_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_partition", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcp_pk": { + "name": "kcp_pk", + "columns": ["connector_id", "partition_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcp_partition_key_check": { + "name": "kcp_partition_key_check", + "value": "octet_length(\"knowledge_connector_partition\".\"partition_key\") BETWEEN 1 AND 1024" + }, + "kcp_context_check": { + "name": "kcp_context_check", + "value": "jsonb_typeof(\"knowledge_connector_partition\".\"context\") = 'object' AND octet_length(\"knowledge_connector_partition\".\"context\"::text) <= 16384" + }, + "kcp_status_check": { + "name": "kcp_status_check", + "value": "\"knowledge_connector_partition\".\"status\" IN ('pending', 'complete', 'blocked')" + }, + "kcp_cursor_check": { + "name": "kcp_cursor_check", + "value": "(\"knowledge_connector_partition\".\"cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"cursor\") <= 393216) AND (\"knowledge_connector_partition\".\"permission_cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"permission_cursor\") <= 393216)" + }, + "kcp_attempts_check": { + "name": "kcp_attempts_check", + "value": "\"knowledge_connector_partition\".\"attempts\" >= 0 AND \"knowledge_connector_partition\".\"permission_attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_grant": { + "name": "knowledge_connector_permission_grant", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kcpg_subject_idx": { + "name": "kcpg_subject_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kcpg_snapshot_fk": { + "name": "kcpg_snapshot_fk", + "tableFrom": "knowledge_connector_permission_grant", + "tableTo": "knowledge_connector_permission_snapshot", + "columnsFrom": ["connector_id"], + "columnsTo": ["connector_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcpg_pk": { + "name": "kcpg_pk", + "columns": ["connector_id", "group_key", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcpg_group_check": { + "name": "kcpg_group_check", + "value": "length(\"knowledge_connector_permission_grant\".\"group_key\") BETWEEN 1 AND 255" + }, + "kcpg_subject_check": { + "name": "kcpg_subject_check", + "value": "\"knowledge_connector_permission_grant\".\"subject_token\" ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_snapshot": { + "name": "knowledge_connector_permission_snapshot", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "kcps_connector_fk": { + "name": "kcps_connector_fk", + "tableFrom": "knowledge_connector_permission_snapshot", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcps_revision_check": { + "name": "kcps_revision_check", + "value": "\"knowledge_connector_permission_snapshot\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcsl_database_failure_class_check": { + "name": "kcsl_database_failure_class_check", + "value": "\"knowledge_connector_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_projection_dirty": { + "name": "knowledge_projection_dirty", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content": { + "name": "content", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marked_at": { + "name": "marked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "knowledge_projection_dirty_marked_at_idx": { + "name": "knowledge_projection_dirty_marked_at_idx", + "columns": [ + { + "expression": "marked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_projection_dirty_document_id_document_id_fk": { + "name": "knowledge_projection_dirty_document_id_document_id_fk", + "tableFrom": "knowledge_projection_dirty", + "tableTo": "document", + "columnsFrom": ["document_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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "storage_version": { + "name": "storage_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "encrypted_context_summary": { + "name": "encrypted_context_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_artifact": { + "name": "memory_artifact", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "memory_artifact_key_idx": { + "name": "memory_artifact_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_artifact_memory_id_memory_id_fk": { + "name": "memory_artifact_memory_id_memory_id_fk", + "tableFrom": "memory_artifact", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_artifact_key_execution_large_values_key_fk": { + "name": "memory_artifact_key_execution_large_values_key_fk", + "tableFrom": "memory_artifact", + "tableTo": "execution_large_values", + "columnsFrom": ["key"], + "columnsTo": ["key"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_artifact_memory_id_key_pk": { + "name": "memory_artifact_memory_id_key_pk", + "columns": ["memory_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_item": { + "name": "memory_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "memory_item_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "append_key": { + "name": "append_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_status": { + "name": "provenance_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_entries": { + "name": "provenance_entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_item_append_unique": { + "name": "memory_item_append_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "append_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_item_sequence_idx": { + "name": "memory_item_sequence_idx", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_item_memory_id_memory_id_fk": { + "name": "memory_item_memory_id_memory_id_fk", + "tableFrom": "memory_item", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_item_kind_check": { + "name": "memory_item_kind_check", + "value": "\"memory_item\".\"kind\" IN ('message', 'exchange')" + }, + "memory_item_provenance_status_check": { + "name": "memory_item_provenance_status_check", + "value": "\"memory_item\".\"provenance_status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_resource_effects": { + "name": "mothership_resource_effects", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effect_id": { + "name": "effect_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_resource_effects_chat_id_copilot_chats_id_fk": { + "name": "mothership_resource_effects_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_resource_effects", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mothership_resource_effects_chat_id_effect_id_pk": { + "name": "mothership_resource_effects_chat_id_effect_id_pk", + "columns": ["chat_id", "effect_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_access_token_search_resource_check": { + "name": "oauth_access_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_access_token\".\"scopes\")) OR \"oauth_access_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + }, + "oauth_refresh_token_search_resource_check": { + "name": "oauth_refresh_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_refresh_token\".\"scopes\")) OR \"oauth_refresh_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "require_sso": { + "name": "require_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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" + }, + "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_access_request_settings": { + "name": "organization_access_request_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "allow_requests": { + "name": "allow_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_access_request_settings_organization_id_organization_id_fk": { + "name": "organization_access_request_settings_organization_id_organization_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_access_request_settings_updated_by_user_id_fk": { + "name": "organization_access_request_settings_updated_by_user_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_history": { + "name": "organization_search_history", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "queries": { + "name": "queries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "organization_search_history_user_idx": { + "name": "organization_search_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_history_organization_id_organization_id_fk": { + "name": "organization_search_history_organization_id_organization_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_history_user_id_user_id_fk": { + "name": "organization_search_history_user_id_user_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_history_organization_id_user_id_pk": { + "name": "organization_search_history_organization_id_user_id_pk", + "columns": ["organization_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_invocation": { + "name": "organization_search_invocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_types": { + "name": "source_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_invocation_org_created_idx": { + "name": "organization_search_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_invocation_user_idx": { + "name": "organization_search_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_invocation_organization_id_organization_id_fk": { + "name": "organization_search_invocation_organization_id_organization_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_invocation_user_id_user_id_fk": { + "name": "organization_search_invocation_user_id_user_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_invocation_result_count_bounds": { + "name": "organization_search_invocation_result_count_bounds", + "value": "\"organization_search_invocation\".\"result_count\" BETWEEN 0 AND 100" + }, + "organization_search_invocation_source_types_bounds": { + "name": "organization_search_invocation_source_types_bounds", + "value": "cardinality(\"organization_search_invocation\".\"source_types\") <= 100" + } + }, + "isRLSEnabled": false + }, + "public.organization_search_mcp_invocation": { + "name": "organization_search_mcp_invocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_mcp_invocation_org_created_idx": { + "name": "organization_search_mcp_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_mcp_invocation_user_idx": { + "name": "organization_search_mcp_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_search_mcp_invocation_org_fk": { + "name": "org_search_mcp_invocation_org_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_search_mcp_invocation_user_fk": { + "name": "org_search_mcp_invocation_user_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_mcp_invocation_tool_check": { + "name": "organization_search_mcp_invocation_tool_check", + "value": "\"organization_search_mcp_invocation\".\"tool_name\" IN ('search', 'read_document', 'chat')" + }, + "organization_search_mcp_invocation_outcome_check": { + "name": "organization_search_mcp_invocation_outcome_check", + "value": "\"organization_search_mcp_invocation\".\"outcome\" IN ('success', 'error', 'cancelled', 'rate_limited')" + }, + "organization_search_mcp_invocation_duration_check": { + "name": "organization_search_mcp_invocation_duration_check", + "value": "\"organization_search_mcp_invocation\".\"duration_ms\" >= 0" + }, + "organization_search_mcp_invocation_client_name_check": { + "name": "organization_search_mcp_invocation_client_name_check", + "value": "length(\"organization_search_mcp_invocation\".\"client_name\") <= 256" + }, + "organization_search_mcp_invocation_auth_check": { + "name": "organization_search_mcp_invocation_auth_check", + "value": "(\"organization_search_mcp_invocation\".\"auth_kind\" = 'oauth_access_token' AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NOT NULL)\n OR (\"organization_search_mcp_invocation\".\"auth_kind\" IN ('personal_api_key', 'workspace_api_key') AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NULL AND \"organization_search_mcp_invocation\".\"client_name\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.organization_secret": { + "name": "organization_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_secret_shared_unique": { + "name": "organization_secret_shared_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_member_unique": { + "name": "organization_secret_member_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_owner_idx": { + "name": "organization_secret_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_id_organization_secret_source_id_fk": { + "name": "organization_secret_source_id_organization_secret_source_id_fk", + "tableFrom": "organization_secret", + "tableTo": "organization_secret_source", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_secret_owner_user_id_user_id_fk": { + "name": "organization_secret_owner_user_id_user_id_fk", + "tableFrom": "organization_secret", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_secret_source": { + "name": "organization_secret_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "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": { + "organization_secret_source_org_unique": { + "name": "organization_secret_source_org_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_organization_id_organization_id_fk": { + "name": "organization_secret_source_organization_id_organization_id_fk", + "tableFrom": "organization_secret_source", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_secret_source_mode_check": { + "name": "organization_secret_source_mode_check", + "value": "\"organization_secret_source\".\"mode\" IN ('organization', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_pending_type_available_idx": { + "name": "outbox_event_pending_type_available_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox_event\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.permission_access_request": { + "name": "permission_access_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_id": { + "name": "requester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "jsonb", + "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()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "permission_access_request_pending_unique": { + "name": "permission_access_request_pending_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"permission_access_request\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_org_queue_idx": { + "name": "permission_access_request_org_queue_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_requester_idx": { + "name": "permission_access_request_requester_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_access_request_organization_id_organization_id_fk": { + "name": "permission_access_request_organization_id_organization_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_requester_id_user_id_fk": { + "name": "permission_access_request_requester_id_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["requester_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_decided_by_user_id_fk": { + "name": "permission_access_request_decided_by_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["decided_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "permission_access_request_status_check": { + "name": "permission_access_request_status_check", + "value": "\"permission_access_request\".\"status\" in ('pending', 'fulfilled', 'declined', 'cancelled', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "project_organization_archive_id_idx": { + "name": "project_organization_archive_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_archive_id_idx": { + "name": "project_owner_archive_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_organization_id_organization_id_fk": { + "name": "project_organization_id_organization_id_fk", + "tableFrom": "project", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_name_length": { + "name": "project_name_length", + "value": "char_length(btrim(\"project\".\"name\")) BETWEEN 1 AND 100" + } + }, + "isRLSEnabled": false + }, + "public.project_workspace": { + "name": "project_workspace", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "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": { + "project_workspace_workspace_id_unique": { + "name": "project_workspace_workspace_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspace_project_id_project_id_fk": { + "name": "project_workspace_project_id_project_id_fk", + "tableFrom": "project_workspace", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_workspace_workspace_id_workspace_id_fk": { + "name": "project_workspace_workspace_id_workspace_id_fk", + "tableFrom": "project_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_workspace_project_id_workspace_id_pk": { + "name": "project_workspace_project_id_workspace_id_pk", + "columns": ["project_id", "workspace_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_state": { + "name": "capacity_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "baseline_permission": { + "name": "baseline_permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_app": { + "name": "slack_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "slack_app_organization_id_organization_id_fk": { + "name": "slack_app_organization_id_organization_id_fk", + "tableFrom": "slack_app", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_app_owner_check": { + "name": "slack_app_owner_check", + "value": "(\"slack_app\".\"kind\" = 'custom' AND \"slack_app\".\"organization_id\" IS NOT NULL) OR (\"slack_app\".\"kind\" = 'shared' AND \"slack_app\".\"organization_id\" IS NULL)" + }, + "slack_app_custom_credentials_check": { + "name": "slack_app_custom_credentials_check", + "value": "\"slack_app\".\"kind\" = 'shared' OR (\"slack_app\".\"client_id\" IS NOT NULL AND \"slack_app\".\"encrypted_client_secret\" IS NOT NULL AND \"slack_app\".\"encrypted_signing_secret\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.slack_search_installation": { + "name": "slack_search_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "credential_version": { + "name": "credential_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_installation_organization_idx": { + "name": "slack_search_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_credential_unique": { + "name": "slack_search_installation_credential_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_app_team_unique": { + "name": "slack_search_installation_app_team_unique", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_active_team_unique": { + "name": "slack_search_installation_active_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_installation\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_installation_organization_id_organization_id_fk": { + "name": "slack_search_installation_organization_id_organization_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_credential_id_credential_id_fk": { + "name": "slack_search_installation_credential_id_credential_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_slack_app_id_slack_app_id_fk": { + "name": "slack_search_installation_slack_app_id_slack_app_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_search_turn": { + "name": "slack_search_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "slack_search_turn_ordinal_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_turn_event_unique": { + "name": "slack_search_turn_event_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_pending_idx": { + "name": "slack_search_turn_pending_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_thread_idx": { + "name": "slack_search_turn_thread_idx", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_active_thread_unique": { + "name": "slack_search_turn_active_thread_unique", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_turn\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_turn_installation_id_slack_search_installation_id_fk": { + "name": "slack_search_turn_installation_id_slack_search_installation_id_fk", + "tableFrom": "slack_search_turn", + "tableTo": "slack_search_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "primary_provider_id": { + "name": "primary_provider_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": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "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'" + }, + "credit_balance": { + "name": "credit_balance", + "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'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost_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_activity_idx": { + "name": "workflow_execution_logs_workspace_activity_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_duration_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fork_sync_new_workflows_excluded": { + "name": "fork_sync_new_workflows_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_build": { + "name": "workspace_file_search_build", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_file_search_build_file_idx": { + "name": "workspace_file_search_build_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_build_cleanup_idx": { + "name": "workspace_file_search_build_cleanup_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_build\".\"expires_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_chunk": { + "name": "workspace_file_search_chunk", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_start": { + "name": "line_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fragment": { + "name": "fragment", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "overlap": { + "name": "overlap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_chunk_line_idx": { + "name": "workspace_file_search_chunk_line_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "line_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_chunk_content_idx": { + "name": "workspace_file_search_chunk_content_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": { + "fastupdate": "off" + } + } + }, + "foreignKeys": { + "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_chunk", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_chunk_pk": { + "name": "workspace_file_search_chunk_pk", + "columns": ["build_id", "ordinal"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_search_chunk_content_size": { + "name": "workspace_file_search_chunk_content_size", + "value": "octet_length(\"workspace_file_search_chunk\".\"content\") <= 8192" + }, + "workspace_file_search_chunk_position": { + "name": "workspace_file_search_chunk_position", + "value": "\"workspace_file_search_chunk\".\"ordinal\" >= 0 AND \"workspace_file_search_chunk\".\"line_start\" > 0 AND \"workspace_file_search_chunk\".\"overlap\" BETWEEN 0 AND 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_revision": { + "name": "workspace_file_search_revision", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "handoff_expires_at": { + "name": "handoff_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_revision_workspace_status_idx": { + "name": "workspace_file_search_revision_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": {} + }, + "workspace_file_search_revision_build_idx": { + "name": "workspace_file_search_revision_build_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_pending_idx": { + "name": "workspace_file_search_revision_pending_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_active_idx": { + "name": "workspace_file_search_revision_active_idx", + "columns": [ + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_revision_file_id_workspace_files_id_fk": { + "name": "workspace_file_search_revision_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_version": { + "name": "workspace_file_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "workspace_file_version_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_user_ids": { + "name": "author_user_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "restored_from_version": { + "name": "restored_from_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_status": { + "name": "secret_provenance_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_entries": { + "name": "secret_provenance_entries", + "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": { + "workspace_file_version_file_version_unique": { + "name": "workspace_file_version_file_version_unique", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_key_unique": { + "name": "workspace_file_version_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_id_idx": { + "name": "workspace_file_version_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_superseded_idx": { + "name": "workspace_file_version_workspace_superseded_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "superseded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_version\".\"superseded_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_version_file_id_workspace_files_id_fk": { + "name": "workspace_file_version_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_version_workspace_id_workspace_id_fk": { + "name": "workspace_file_version_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_version_provenance_status_check": { + "name": "workspace_file_version_provenance_status_check", + "value": "\"workspace_file_version\".\"secret_provenance_status\" IS NULL OR \"workspace_file_version\".\"secret_provenance_status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "date_trunc('milliseconds', now())" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_active_keyset_idx": { + "name": "workspace_files_workspace_active_keyset_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_workflow_sync": { + "name": "workspace_fork_workflow_sync", + "schema": "", + "columns": { + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workflow_id": { + "name": "source_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_deployment_version_id": { + "name": "source_deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "workspace_fork_workflow_sync_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "promote_run_id": { + "name": "promote_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rollback_operation_id": { + "name": "rollback_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rolled_back_at": { + "name": "rolled_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_fork_workflow_sync_baseline_idx": { + "name": "workspace_fork_workflow_sync_baseline_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_fork_workflow_sync\".\"activated_at\" IS NOT NULL AND \"workspace_fork_workflow_sync\".\"rolled_back_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_run_idx": { + "name": "workspace_fork_workflow_sync_run_idx", + "columns": [ + { + "expression": "promote_run_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_workflow_sync_rollback_idx": { + "name": "workspace_fork_workflow_sync_rollback_idx", + "columns": [ + { + "expression": "rollback_operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_fork_workflow_sync\".\"rollback_operation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_source_idx": { + "name": "workspace_fork_workflow_sync_source_idx", + "columns": [ + { + "expression": "source_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_target_idx": { + "name": "workspace_fork_workflow_sync_target_idx", + "columns": [ + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_child_workspace_idx": { + "name": "workspace_fork_workflow_sync_child_workspace_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_workflow_sync_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_workflow_sync_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_workflow_sync_source_workflow_id_workflow_id_fk": { + "name": "workspace_fork_workflow_sync_source_workflow_id_workflow_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workflow", + "columnsFrom": ["source_workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_workflow_sync_target_workflow_id_workflow_id_fk": { + "name": "workspace_fork_workflow_sync_target_workflow_id_workflow_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workflow", + "columnsFrom": ["target_workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operation_receipt": { + "name": "workspace_operation_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operation_receipt_request_unique": { + "name": "workspace_operation_receipt_request_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operation_receipt_workspace_created_idx": { + "name": "workspace_operation_receipt_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operation_receipt_workspace_id_workspace_id_fk": { + "name": "workspace_operation_receipt_workspace_id_workspace_id_fk", + "tableFrom": "workspace_operation_receipt", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_visit": { + "name": "workspace_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visited_at": { + "name": "visited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_visit_workspace_idx": { + "name": "workspace_visit_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_visit_user_id_user_id_fk": { + "name": "workspace_visit_user_id_user_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_visit_workspace_id_workspace_id_fk": { + "name": "workspace_visit_workspace_id_workspace_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_visit_user_id_workspace_id_pk": { + "name": "workspace_visit_user_id_workspace_id_pk", + "columns": ["user_id", "workspace_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "organization_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_file_version_source": { + "name": "workspace_file_version_source", + "schema": "public", + "values": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index c60849611a7..1584f85feba 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2752,6 +2752,13 @@ "when": 1790973503076, "tag": "0393_fork_workflow_sync_provenance", "breakpoints": true + }, + { + "idx": 394, + "version": "7", + "when": 1790986588983, + "tag": "0394_project_foundation", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5f0b29e1855..5d506b0dfcb 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2058,6 +2058,55 @@ export const workspace = pgTable( }) ) +/** Stable owner of environments and project-wide resources, independent of fork lineage. */ +export const project = pgTable( + 'project', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'restrict', + }), + /** Lifecycle owner for personal and organization Projects; never an implicit access grant. */ + ownerId: text('owner_id') + .notNull() + .references(() => user.id, { onDelete: 'restrict' }), + archivedAt: timestamp('archived_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + nameLength: check( + 'project_name_length', + sql`char_length(btrim(${table.name})) BETWEEN 1 AND 100` + ), + organizationIdx: index('project_organization_archive_id_idx').on( + table.organizationId, + table.archivedAt, + table.id + ), + ownerIdx: index('project_owner_archive_id_idx').on(table.ownerId, table.archivedAt, table.id), + }) +) + +// contract-pending(after project writers are fully deployed and backfill validates): enforce exactly-one membership and active Project environment minimums at commit. +export const projectWorkspace = pgTable( + 'project_workspace', + { + projectId: text('project_id') + .notNull() + .references(() => project.id, { onDelete: 'restrict' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.projectId, table.workspaceId] }), + workspaceUnique: uniqueIndex('project_workspace_workspace_id_unique').on(table.workspaceId), + }) +) + export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_type', [ 'workflow', 'oauth_credential', diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index aad83e0a553..84efd2e93f6 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -2590,6 +2590,7 @@ export type CreatePermissionGroupBody = { hideSandboxesTab?: boolean disableOAuthAppAccess?: boolean disableKnowledgeBaseExport?: boolean + deniedPartialAccessProjectIssues?: Array } isDefault?: boolean workspaceIds?: Array @@ -2643,6 +2644,7 @@ type CreatePermissionGroupResponseRef0 = { hideSandboxesTab: boolean disableOAuthAppAccess: boolean disableKnowledgeBaseExport: boolean + deniedPartialAccessProjectIssues: Array } isDefault: boolean membershipMode: string @@ -6129,6 +6131,7 @@ type GetPermissionGroupResponseRef0 = { hideSandboxesTab: boolean disableOAuthAppAccess: boolean disableKnowledgeBaseExport: boolean + deniedPartialAccessProjectIssues: Array } isDefault: boolean membershipMode: string @@ -7279,6 +7282,7 @@ type GetWorkspacePermissionConfigResponseRef0 = { hideSandboxesTab: boolean disableOAuthAppAccess: boolean disableKnowledgeBaseExport: boolean + deniedPartialAccessProjectIssues: Array } | null entitled: boolean organizationId: string | null @@ -8956,6 +8960,7 @@ type ListPermissionGroupsResponseRef0 = { hideSandboxesTab: boolean disableOAuthAppAccess: boolean disableKnowledgeBaseExport: boolean + deniedPartialAccessProjectIssues: Array } isDefault: boolean membershipMode: string @@ -10057,6 +10062,7 @@ type PreviewOrganizationAccessRequestResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'deniedPartialAccessProjectIssues' label: string before: boolean | Array | null after: boolean | Array | null @@ -10213,6 +10219,7 @@ type PreviewOrganizationAccessRequestResponseRef1 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'deniedPartialAccessProjectIssues' label: string before: boolean | Array | null after: boolean | Array | null @@ -13243,6 +13250,7 @@ export type UpdatePermissionGroupBody = { hideSandboxesTab?: boolean disableOAuthAppAccess?: boolean disableKnowledgeBaseExport?: boolean + deniedPartialAccessProjectIssues?: Array } isDefault?: boolean workspaceIds?: Array @@ -13296,6 +13304,7 @@ type UpdatePermissionGroupResponseRef0 = { hideSandboxesTab: boolean disableOAuthAppAccess: boolean disableKnowledgeBaseExport: boolean + deniedPartialAccessProjectIssues: Array } isDefault: boolean membershipMode: string diff --git a/packages/testing/src/mocks/schema-tables.generated.ts b/packages/testing/src/mocks/schema-tables.generated.ts index 2492ec37f23..108730788fd 100644 --- a/packages/testing/src/mocks/schema-tables.generated.ts +++ b/packages/testing/src/mocks/schema-tables.generated.ts @@ -501,6 +501,8 @@ export const GENERATED_SCHEMA_TABLES = { 'createdAt', 'updatedAt', ], + project: ['id', 'name', 'organizationId', 'ownerId', 'archivedAt', 'createdAt', 'updatedAt'], + projectWorkspace: ['projectId', 'workspaceId', 'createdAt'], workspaceForkResourceMap: [ 'id', 'childWorkspaceId', diff --git a/scripts/test-integration.ts b/scripts/test-integration.ts index 1a5e4eee27b..1f7a70f81e6 100644 --- a/scripts/test-integration.ts +++ b/scripts/test-integration.ts @@ -11,6 +11,7 @@ import { generateId } from '@sim/utils/id' * Creates and removes its own Postgres and Redis containers, provisions the schema the way CI does, * and runs `vitest run --mode integration` in `packages/db` and `apps/sim` with `TEST_DATABASE_URL` * and `TEST_REDIS_URL`; it never reads an application DSN. + * Set INTEGRATION_DB_PROVISION=migrate to exercise the versioned SQL migration path. * Set KNOWLEDGE_SCALE_TEST=true for the opt-in scale suite; its JSON report is saved in tmpdir. * Optional positional `apps/sim` Vitest filename filters limit a diagnostic run (and skip * `packages/db`); omit them for full validation. @@ -20,6 +21,9 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const container = `sim-integration-test-${generateId()}` const redisContainer = `${container}-redis` const database = 'sim_test' +const provision = process.env.INTEGRATION_DB_PROVISION ?? 'push' +if (provision !== 'push' && provision !== 'migrate') + throw new Error('INTEGRATION_DB_PROVISION must be push or migrate') const scale = process.env.KNOWLEDGE_SCALE_TEST === 'true' const searchPerformance = process.env.KNOWLEDGE_SEARCH_PERFORMANCE_TEST === 'true' const testFilters = process.argv.slice(2) @@ -158,12 +162,12 @@ try { TEST_REDIS_URL: `redis://${redisEndpoint}`, ...(scale ? { KNOWLEDGE_SCALE_REPORT_FILE: scaleReportFile } : {}), } - run('bun', ['run', 'db:push'], { cwd: path.join(root, 'packages/db'), env: environment }) + run('bun', ['run', `db:${provision}`], { cwd: path.join(root, 'packages/db'), env: environment }) run( - 'bunx', + 'bun', [ - 'vitest', 'run', + 'test', '--mode', 'integration', ...(scale ? ['lib/knowledge/__integration__/scale.integration.ts'] : testFilters), @@ -174,7 +178,7 @@ try { } ) if (!scale && testFilters.length === 0) { - run('bunx', ['vitest', 'run', '--mode', 'integration'], { + run('bun', ['run', 'test', '--mode', 'integration'], { cwd: path.join(root, 'packages/db'), env: environment, }) From a865f47a449cb31570d128afbc5ab61086397ec5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 5 Oct 2026 12:33:08 -0700 Subject: [PATCH 09/68] fix(mcp): restrict MCP server destination changes to admins (#8629) * fix(mcp): restrict MCP server destination changes to admins * fix(mcp): compare exact MCP paths and guard concurrent URL changes * fix(mcp): treat setting a URL on a URL-less server as a destination change * fix(mcp): guard re-registration against concurrent URL changes * fix(mcp): narrow re-registration URL before the guarded update * chore(mcp): use absolute import in utils test --- apps/sim/app/api/mcp/servers/[id]/route.ts | 3 +- .../lib/mcp/application/operations.test.ts | 1 + apps/sim/lib/mcp/application/operations.ts | 9 + .../sim/lib/mcp/application/use-cases.test.ts | 45 ++++- apps/sim/lib/mcp/application/use-cases.ts | 21 ++- .../orchestration/server-lifecycle.test.ts | 169 +++++++++++++++++- .../lib/mcp/orchestration/server-lifecycle.ts | 75 +++++++- apps/sim/lib/mcp/utils.test.ts | 18 +- apps/sim/lib/mcp/utils.ts | 15 ++ scripts/check-unused-exports.baseline.json | 1 - 10 files changed, 342 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index 5a8a4073c5c..e13a844f911 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -30,7 +30,7 @@ export const PATCH = withRouteHandler( )( async ( request: NextRequest, - { userId, userName, userEmail, workspaceId, requestId }, + { userId, userName, userEmail, workspaceId, requestId, permission }, { params } ) => { try { @@ -59,6 +59,7 @@ export const PATCH = withRouteHandler( actorName: userName, actorEmail: userEmail, serverId, + allowDestinationChange: permission === 'admin', name: body.name, description: body.description, transport: body.transport, diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index c2bcc29e046..e03ab42f73c 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -132,6 +132,7 @@ const EXPECTED_CAPABILITIES: Record = register: 'mcp_tools.use', update: 'mcp_tools.use', reconfigure: 'mcp_tools.use', + changeDestination: 'mcp_tools.use', delete: 'mcp_tools.use', discoverTools: 'mcp_tools.use', executeTool: 'mcp_tools.use', diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index 16cfb9c3f0f..1f38e05b576 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -209,6 +209,15 @@ export const mcpServerOperations = { capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), + /** Pointing a server at another host or path; deployed workflows pin servers by id. */ + changeDestination: defineWorkspaceOperation({ + id: 'mcp_servers.change_destination', + oauthScope: 'api:write', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'mcp_tools.use', + ...HUMAN_PRINCIPAL_POLICY, + }), delete: defineWorkspaceOperation({ id: 'mcp_servers.delete', oauthScope: 'api:write', diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index a4ba25d0fbc..c1d1a4f0bb9 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -12,12 +12,14 @@ import { workspaceUploadsMockFns, } from '@sim/testing/mocks/workspace-uploads.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' const { events, hoisted } = vi.hoisted(() => ({ events: [] as string[], hoisted: { idState: vi.fn(), create: vi.fn(), + update: vi.fn(), effects: vi.fn(), getServer: vi.fn(), listServers: vi.fn(), @@ -31,7 +33,7 @@ vi.mock('@/lib/mcp/orchestration', () => ({ applyMcpServerMutationEffects: hoisted.effects, createMcpServer: hoisted.create, deleteMcpServer: vi.fn(), - updateMcpServer: vi.fn(), + updateMcpServer: hoisted.update, })) vi.mock('@/lib/mcp/queries', () => ({ getMcpServerIdState: hoisted.idState, @@ -45,6 +47,7 @@ import { discoverMcpServerToolsUseCase, discoverMcpToolsUseCase, getMcpServerUseCase, + reconfigureMcpServerUseCase, } from '@/lib/mcp/application/use-cases' const mocks = { @@ -109,6 +112,46 @@ describe('MCP server application use cases', () => { mocks.discoverServerTools.mockResolvedValue([]) }) + it('refuses a writer pointing a server at a different host before writing', async () => { + await expect( + reconfigureMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1' }, + input: { + workspaceId: workspace.workspaceId, + serverId: server.id, + url: 'https://other-host.example.com/mcp', + }, + }) + ).rejects.toBeInstanceOf(InsufficientWorkspacePermissionsError) + }) + + it('lets a writer change only the query string, and an admin change the host', async () => { + mocks.update.mockResolvedValue({ success: true, server, configurationChanged: true }) + + await expect( + reconfigureMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1' }, + input: { + workspaceId: workspace.workspaceId, + serverId: server.id, + url: `${server.url}?token=rotated`, + }, + }) + ).resolves.toMatchObject({ server: { id: server.id } }) + + mocks.resolvePermission.mockResolvedValue('admin') + await expect( + reconfigureMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1' }, + input: { + workspaceId: workspace.workspaceId, + serverId: server.id, + url: 'https://new.example.com/mcp', + }, + }) + ).resolves.toMatchObject({ server: { id: server.id } }) + }) + it('resolves a selected organization server through canonical scope and current permissions', async () => { mocks.loadContext.mockResolvedValue({ ...workspace, workspaceOrganizationId: 'org-1' }) const args = { diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index 58de636ee78..68732ed1ecf 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' import { getPostgresErrorCode } from '@sim/utils/errors' import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' -import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { + authorizeWorkspaceOperation, + defineAuthorizedWorkspaceUseCase, + ForbiddenOperationError, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { @@ -37,7 +41,7 @@ import { import { mcpService } from '@/lib/mcp/service' import { compileMcpToolSchema } from '@/lib/mcp/tool-schema' import type { McpAuthType } from '@/lib/mcp/types' -import { generateMcpServerId } from '@/lib/mcp/utils' +import { generateMcpServerId, isSameMcpServerDestination } from '@/lib/mcp/utils' type McpServerTransport = McpServerRow['transport'] type McpWriteSource = 'api' | 'settings' | 'tool_input' @@ -386,6 +390,18 @@ async function updateMcpServer(args: { 'This MCP server is managed from its Credential Group settings' ) } + const changesDestination = + args.input.url !== undefined && + (!args.context.server.url || + !isSameMcpServerDestination(args.context.server.url, args.input.url)) + if (changesDestination) { + await authorizeWorkspaceOperation( + args.principal, + mcpServerOperations.changeDestination, + args.context, + authorizationOptions + ) + } const attribution = resolvePrincipalAttribution(args.principal, { workspaceBillingOwnerUserId: args.context.billedAccountUserId, }) @@ -393,6 +409,7 @@ async function updateMcpServer(args: { workspaceId: args.context.workspaceId, userId: attribution.attributedUserId, serverId: args.context.server.id, + allowDestinationChange: changesDestination, name: args.input.name, description: args.input.description, transport: args.input.transport, diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index cd0370c8453..21636d5ebb2 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -29,7 +29,16 @@ vi.mock('@/lib/mcp/domain-check', () => ({ })) vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) vi.mock('@/lib/mcp/service', () => mcpServiceMock) -vi.mock('@/lib/mcp/utils', () => ({ generateMcpServerId: mockGenerateMcpServerId })) +vi.mock('@/lib/mcp/utils', () => ({ + generateMcpServerId: mockGenerateMcpServerId, + isSameMcpServerDestination: (a: string, b: string) => { + const destination = (url: string) => { + const parsed = new URL(url) + return `${parsed.origin}${parsed.pathname}` + } + return destination(a) === destination(b) + }, +})) vi.mock('@/lib/posthog/server', () => posthogServerMock) import { @@ -74,6 +83,7 @@ describe('MCP server lifecycle orchestration', () => { workspaceId: 'workspace-1', userId: 'user-1', serverId: 'server-1', + allowDestinationChange: false, oauthClientId: 'client-1', oauthClientIdProvided: true, }) @@ -116,6 +126,7 @@ describe('MCP server lifecycle orchestration', () => { workspaceId: 'workspace-1', userId: 'user-1', serverId: 'server-1', + allowDestinationChange: false, authType: 'headers', }) @@ -163,6 +174,7 @@ describe('MCP server lifecycle orchestration', () => { workspaceId: 'workspace-1', userId: 'user-1', serverId: 'server-1', + allowDestinationChange: false, headers: { authorization: 'Bearer rotated' }, }) @@ -181,6 +193,7 @@ describe('MCP server lifecycle orchestration', () => { it('resets to disconnected when a create/upsert flips an existing OAuth server to headers', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }]) dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'server-1', @@ -225,6 +238,157 @@ describe('MCP server lifecycle orchestration', () => { expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') }) + it('refuses a non-admin pointing an existing server at a different host', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + url: 'https://example.com/mcp', + authType: 'headers', + headers: {}, + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + + const result = await performUpdateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + allowDestinationChange: false, + url: 'https://other-host.example.com/mcp', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'forbidden' }) + }) + + it('refuses a non-admin setting a URL on a server that has none', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + url: null, + authType: 'headers', + headers: {}, + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + + const result = await performUpdateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + allowDestinationChange: false, + url: 'https://other-host.example.com/mcp', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'forbidden' }) + }) + + it('refuses a non-admin save when the URL changed after it was checked', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + url: 'https://example.com/mcp', + authType: 'headers', + headers: {}, + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await performUpdateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + allowDestinationChange: false, + url: 'https://example.com/mcp?token=rotated', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('lets an admin point an existing server at a different host', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + url: 'https://example.com/mcp', + authType: 'headers', + headers: {}, + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + url: 'https://new.example.com/mcp', + authType: 'headers', + }, + ]) + + const result = await performUpdateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + allowDestinationChange: true, + url: 'https://new.example.com/mcp', + }) + + expect(result).toMatchObject({ + success: true, + server: { url: 'https://new.example.com/mcp' }, + }) + }) + + it('refuses a registration whose id collides with a server at a different host', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://other-host.example.com/collide', + authType: 'headers', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('refuses a re-registration when the URL changed after it was checked', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://example.com/mcp', + authType: 'headers', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + it('registers a new server as disconnected rather than stamping a connection it never made', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') dbChainMockFns.limit.mockResolvedValueOnce([]) @@ -286,6 +450,7 @@ describe('MCP server lifecycle orchestration', () => { it('leaves a re-registered server disconnected until discovery re-runs', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }]) dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'server-1', @@ -335,6 +500,7 @@ describe('MCP server lifecycle orchestration', () => { */ it('keeps an OAuth server connected through a re-registration that only renames it', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }]) dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'server-1', @@ -380,6 +546,7 @@ describe('MCP server lifecycle orchestration', () => { it('resets a re-registered server whose transport changes', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }]) dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'server-1', diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index aa893f3fde4..b3d24fed276 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -18,7 +18,7 @@ import { import { detectMcpAuthType, oauthCredsChanged, revokeMcpOauthTokens } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' import type { McpAuthType } from '@/lib/mcp/types' -import { generateMcpServerId } from '@/lib/mcp/utils' +import { generateMcpServerId, isSameMcpServerDestination } from '@/lib/mcp/utils' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('McpServerOrchestration') @@ -62,6 +62,11 @@ export interface PerformUpdateMcpServerParams extends ActorMetadata { workspaceId: string userId: string serverId: string + /** + * Whether the caller may point the server at a different host or path. + * Deployed workflows pin a server by id, so this is reserved for admins. + */ + allowDestinationChange: boolean name?: string description?: string | null transport?: McpServerTransport @@ -178,6 +183,22 @@ export async function createMcpServer( const urlChanged = existingServer ? existingServer.url !== params.url : true + /** + * Server ids are a 32-bit hash of the URL, so a different destination can + * collide with an existing row. Deployed workflows pin that id, so an + * upsert must never repoint it at another host. + */ + if ( + existingServer && + (!existingServer.url || !isSameMcpServerDestination(existingServer.url, params.url)) + ) { + return { + success: false, + error: 'An MCP server with a conflicting id already exists in this workspace', + errorCode: 'conflict', + } + } + if (existingServer?.managedConnectorId) { return { success: false, @@ -249,11 +270,10 @@ export async function createMcpServer( if (shouldClearOauth) await revokeMcpOauthTokens(serverId, params.workspaceId) + const checkedUrl = existingServer.url + if (!checkedUrl) throw new Error(`MCP server ${serverId} has no URL to re-register against`) let updatedFields: string[] = [] - await db.transaction(async (tx) => { - if (shouldClearOauth) { - await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId)) - } + const rewritten = await db.transaction(async (tx) => { const updateValues: Partial = { name: params.name, description: params.description, @@ -301,8 +321,25 @@ export async function createMcpServer( updatedFields = Object.entries(updateValues) .filter(([key, value]) => key !== 'updatedAt' && value !== undefined) .map(([key]) => key) - await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId)) + /** Matching the checked URL keeps a concurrent admin repoint from being written back. */ + const [updated] = await tx + .update(mcpServers) + .set(updateValues) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.url, checkedUrl))) + .returning({ id: mcpServers.id }) + if (!updated) return false + if (shouldClearOauth) { + await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId)) + } + return true }) + if (!rewritten) { + return { + success: false, + error: 'The MCP server URL changed while saving; reload and try again', + errorCode: 'conflict', + } + } const [server] = await db .select() @@ -417,6 +454,19 @@ export async function updateMcpServer( if (!currentServer) return { success: false, error: 'Server not found', errorCode: 'not_found' } + const guardedUrl = params.allowDestinationChange ? undefined : params.url + if ( + guardedUrl !== undefined && + (!currentServer.url || !isSameMcpServerDestination(currentServer.url, guardedUrl)) + ) { + return { + success: false, + error: 'Only workspace admins can point an MCP server at a different URL', + errorCode: 'forbidden', + } + } + const checkedUrl = guardedUrl !== undefined ? currentServer.url : null + if ( params.oauthClientId && currentServer.authType !== 'oauth' && @@ -474,7 +524,8 @@ export async function updateMcpServer( and( eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId), - isNull(mcpServers.deletedAt) + isNull(mcpServers.deletedAt), + checkedUrl ? eq(mcpServers.url, checkedUrl) : undefined ) ) .returning() @@ -487,7 +538,15 @@ export async function updateMcpServer( return updated }) - if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } + if (!server) { + return checkedUrl + ? { + success: false, + error: 'The MCP server URL changed while saving; reload and try again', + errorCode: 'conflict', + } + : { success: false, error: 'Server not found', errorCode: 'not_found' } + } const shouldClearCache = urlChanged || diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index c916fd19bcd..251ab3b7063 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -10,9 +10,10 @@ import { generateManagedMcpConnectionId, generateMcpServerId, isManagedMcpConnectionId, + isSameMcpServerDestination, parseMcpToolId, parseMcpToolTarget, -} from './utils' +} from '@/lib/mcp/utils' describe('generateMcpServerId', () => { const workspaceId = 'ws-test-123' @@ -49,6 +50,21 @@ describe('generateMcpServerId', () => { }) }) +describe('isSameMcpServerDestination', () => { + it('ignores only the query string and fragment', () => { + const url = 'https://mcp.example.com/mcp' + expect(isSameMcpServerDestination(url, `${url}?token=abc#x`)).toBe(true) + expect(isSameMcpServerDestination(url, 'https://MCP.example.com/mcp')).toBe(true) + }) + + it('treats a different host, path case, or trailing slash as a new destination', () => { + const url = 'https://mcp.example.com/mcp' + expect(isSameMcpServerDestination(url, 'https://other.example.com/mcp')).toBe(false) + expect(isSameMcpServerDestination(url, 'https://mcp.example.com/MCP')).toBe(false) + expect(isSameMcpServerDestination(url, `${url}/`)).toBe(false) + }) +}) + describe('categorizeError', () => { it.concurrent('returns 401 for McpOauthAuthorizationRequiredError via instanceof', () => { const error = new McpOauthAuthorizationRequiredError('mcp-a', 'A') diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index 4cd2dad2ad3..4745a83a269 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -243,6 +243,21 @@ export function generateMcpServerId(workspaceId: string, url: string): string { return `mcp-${hash}` } +/** + * Whether two URLs name the same MCP server destination: the same origin and + * exact path. Only the query string and fragment may differ — paths can be + * case-sensitive, so this is stricter than the id hash. + */ +export function isSameMcpServerDestination(a: string, b: string): boolean { + try { + const parsedA = new URL(a) + const parsedB = new URL(b) + return parsedA.origin === parsedB.origin && parsedA.pathname === parsedB.pathname + } catch { + return a === b + } +} + /** * Normalize URL for consistent hashing. * - Converts to lowercase diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 013b0444c84..37af086f027 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -4823,7 +4823,6 @@ "apps/sim/lib/managed-agents/session-client.ts#SessionStopReason", "apps/sim/lib/managed-agents/session-client.ts#SessionUsage", "apps/sim/lib/mcp/application/operations.ts#McpServerOperation", - "apps/sim/lib/mcp/application/use-cases.ts#reconfigureMcpServerUseCase", "apps/sim/lib/mcp/application/use-cases.ts#registerMcpServerUseCase", "apps/sim/lib/mcp/constants.ts#MAX_MCP_PARAMETER_SCHEMA_BYTES=MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES", "apps/sim/lib/mcp/constants.ts#MAX_MCP_SERVER_TOOLS_METADATA_BYTES=MAX_MCP_TOOLS_LIST_RESPONSE_BYTES", From 2c8eeaf3c3e1e3eaaceeffd636439a05c5219b26 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 12:58:56 -0700 Subject: [PATCH 10/68] fix(code-placeholders): reject shell arithmetic placeholders (#8628) --- .../code-placeholders/compiler.test.ts | 58 +++++++++++++++++++ .../lib/execution/code-placeholders/shell.ts | 56 +++++++++++++++--- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index d7ba6b94d1c..96cc224341e 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1039,6 +1039,64 @@ describe('code placeholder compiler', () => { } }) + it.each([ + 'total=$(( {{KEY}} * 2 ))', + 'printf "%s" "$(( {{KEY}} * 2 ))"', + 'total=$(( "{{KEY}}" * 2 ))', + 'total=$[ {{KEY}} * 2 ]', + 'printf "%s" "$[ {{KEY}} * 2 ]"', + 'total=$[ values[0] + {{KEY}} ]', + '(( total = {{KEY}} * 2 ))', + 'for (( i = {{KEY}}; i < 2; i++ )); do :; done', + 'total=$(( $(printf "%s" "{{KEY}}") * 2 ))', + 'total=$(( `printf "%s" "{{KEY}}"` * 2 ))', + 'total=$(( $(( 1 + 1 )) + {{KEY}} ))', + 'cat < { + await expect( + compileCodePlaceholders({ + code, + language: CodeLanguage.Shell, + environmentVariables: { KEY: 'values[$(printf injected >&2)]' }, + }) + ).rejects.toThrow('is not supported in shell arithmetic') + }) + + it('preserves shell literal arithmetic text and leaves completed arithmetic frames', async () => { + const value = 'values[$(printf injected >&2)]' + const compiled = await compileCodePlaceholders({ + code: [ + 'printf "%s\\n" "{{KEY}}"', + "printf '%s\\n' '$(( {{KEY}} ))'", + "printf '%s\\n' '$[ {{KEY}} ]'", + 'printf "%s\\n" "$(printf %s "{{KEY}}")"', + 'printf "%s\\n" "$(( 1 + 1 )){{KEY}}"', + 'printf "%s\\n" "$[ values[0] + 2 ]{{KEY}}"', + '(( total = 2 )); printf "%s\\n" "{{KEY}}"', + 'cat < { + const code = 'total=$(( {{MISSING}} + {{KEY}} ))' + await expect(analyzeCodePlaceholders(code, CodeLanguage.Shell)).resolves.toEqual([ + 'MISSING', + 'KEY', + ]) + const compiled = await compileCodePlaceholders({ code, language: CodeLanguage.Shell }) + expect(compiled.code).toBe(code) + }) + it('renders quoted shell heredocs nested in double-quoted command substitutions', async () => { const compiled = await compileCodePlaceholders({ code: [ diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index 95677620ef2..efa1660a309 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -31,11 +31,14 @@ interface ShellScanFrame { kind: 'root' | 'command' | 'arithmetic' | 'backtick' quote: ShellQuote parenthesisDepth: number + bracketDepth?: number literalRoot: boolean } interface ShellOccurrenceContext { quote: ShellQuote + /** Includes nested command substitutions whose output can become an arithmetic operand. */ + arithmetic?: boolean unsupported?: 'escaped sequence' } @@ -445,10 +448,11 @@ function isShellAssignmentName(code: string, occurrence: CodePlaceholderOccurren function getUnsupportedShellPosition( code: string, occurrence: CodePlaceholderOccurrence, - quote: ShellQuote + context: ShellOccurrenceContext ): string | undefined { + if (context.arithmetic) return 'in shell arithmetic' if (code[occurrence.start - 1] === '$') return 'immediately after "$"' - if (quote !== 'none') return undefined + if (context.quote !== 'none') return undefined const lineStart = Math.max( code.lastIndexOf('\n', occurrence.start - 1), @@ -488,6 +492,7 @@ function collectShellOccurrenceContexts( { kind: 'root', quote: 'none', parenthesisDepth: 0, literalRoot }, ] let skippedRangeIndex = 0 + let arithmeticDepth = 0 for (let index = start; index < end; ) { const frame = frames.at(-1) @@ -507,7 +512,7 @@ function collectShellOccurrenceContexts( const occurrence = occurrenceByStart.get(index) if (occurrence) { - contexts.set(occurrence, { quote: frame.quote }) + contexts.set(occurrence, { quote: frame.quote, arithmetic: arithmeticDepth > 0 }) index = occurrence.end continue } @@ -533,6 +538,24 @@ function collectShellOccurrenceContexts( } continue } + const arithmeticExpansion = + character === '$' && + ((code[index + 1] === '(' && code[index + 2] === '(') || code[index + 1] === '[') + const arithmeticCommand = + frame.quote === 'none' && !frame.literalRoot && shellArithmeticCommandStarts(code, index) + if (arithmeticExpansion || arithmeticCommand) { + const brackets = arithmeticExpansion && code[index + 1] === '[' + frames.push({ + kind: 'arithmetic', + quote: 'none', + parenthesisDepth: brackets ? 0 : 2, + ...(brackets ? { bracketDepth: 1 } : {}), + literalRoot: false, + }) + arithmeticDepth += 1 + index += arithmeticExpansion && !brackets ? 3 : 2 + continue + } if (frame.quote === 'double') { if (character === '\\') { const escaped = occurrenceByStart.get(index + 1) @@ -572,7 +595,7 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (!frame.literalRoot && shellCommentStarts(code, index)) { + if (frame.kind !== 'arithmetic' && !frame.literalRoot && shellCommentStarts(code, index)) { const newline = code.indexOf('\n', index) index = newline === -1 || newline >= end ? end : newline + 1 continue @@ -622,14 +645,29 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (frame.kind === 'command' && character === '(') { + if (frame.kind === 'arithmetic' && frame.bracketDepth !== undefined) { + if (character === '[') frame.bracketDepth += 1 + if (character === ']') { + frame.bracketDepth -= 1 + if (frame.bracketDepth === 0) { + frames.pop() + arithmeticDepth -= 1 + } + } + index += 1 + continue + } + if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === '(') { frame.parenthesisDepth += 1 index += 1 continue } - if (frame.kind === 'command' && character === ')') { + if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === ')') { frame.parenthesisDepth -= 1 - if (frame.parenthesisDepth === 0) frames.pop() + if (frame.parenthesisDepth === 0) { + frames.pop() + if (frame.kind === 'arithmetic') arithmeticDepth -= 1 + } index += 1 continue } @@ -849,7 +887,7 @@ export async function compileShellPlaceholders( const unsupportedPosition = getUnsupportedShellPosition( input.code, occurrence, - occurrenceContext.quote + occurrenceContext ) if (unsupportedPosition) { if (context.hasValue(occurrence.name)) { @@ -904,7 +942,7 @@ export async function compileShellPlaceholders( const unsupportedPosition = getUnsupportedShellPosition( input.code, occurrence, - occurrenceContext.quote + occurrenceContext ) if (unsupportedPosition) { if (context.hasValue(occurrence.name)) { From 6b3cd82fc5e7f512a9c22ae9209c1d70806b43ee Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 5 Oct 2026 14:49:23 -0700 Subject: [PATCH 11/68] fix(credentials): keep the field-less GitHub App installation out of v2 provider discovery (#8632) --- .../application/list-credential-providers.ts | 9 +- .../provider-catalog-contract.test.ts | 103 ++++++++++++++++++ .../sim-cli/src/commands/credentials.test.ts | 55 ++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/credentials/application/provider-catalog-contract.test.ts diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts index ded16457d4d..c7ca08e6db3 100644 --- a/apps/sim/lib/credentials/application/list-credential-providers.ts +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -6,6 +6,7 @@ import { type CredentialProviderCatalogEntry, listCredentialProviderCatalog, } from '@/lib/credentials/application/provider-catalog' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ListCredentialProvidersInput { @@ -31,7 +32,13 @@ export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ throw new OrchestrationError('validation', 'search cannot be empty') } - const providers = await listCredentialProviderCatalog(principal, context) + // A GitHub App installation is connected through Search integrations, never credential + // creation, so it has no create fields and stays out of public discovery. + const providers = (await listCredentialProviderCatalog(principal, context)).filter( + (provider) => + provider.type !== 'service_account' || + provider.providerId !== GITHUB_INSTALLATION_PROVIDER_ID + ) return { providers: search ? providers.filter((provider) => provider.name.toLowerCase().includes(search)) diff --git a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts new file mode 100644 index 00000000000..4eaed355f21 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts @@ -0,0 +1,103 @@ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { blockVisibilityMock } from '@sim/testing/mocks/block-visibility.mock' +import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' +import { + workspaceContextMock, + workspaceContextMockFns, +} from '@sim/testing/mocks/workspace-context.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hoisted = vi.hoisted(() => ({ + allowedIntegrationTypes: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => blockVisibilityMock) +vi.mock('@/lib/workspaces/application/workspace-context', () => workspaceContextMock) +vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) +vi.mock('@/lib/integrations/principal-scope.server', () => ({ + principalUserId: () => 'user-1', + allowedIntegrationTypes: hoisted.allowedIntegrationTypes, + allowedOrganizationIntegrationTypes: hoisted.allowedIntegrationTypes, +})) + +import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' +import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' + +const CLAUDE_PROVIDER_ID = 'claude-platform-service-account' +const GITHUB_INSTALLATION_PROVIDER_ID = 'github-app-installation' +const context = { workspaceId: 'workspace-1', workspaceOrganizationId: null } +const responseSchema = v2ListCredentialProvidersContract.response.schema + +async function listProviders(search?: string) { + const { providers } = await listCredentialProviders.execute({ + principal: createSessionPrincipal(), + input: { workspaceId: 'workspace-1', ...(search ? { search } : {}) }, + }) + return providers +} + +describe('v2 credential provider catalog contract', () => { + beforeEach(() => { + hoisted.allowedIntegrationTypes.mockResolvedValue(null) + workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({ + ...context, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('read') + }) + + it('presents the full unfiltered catalog as a valid response', async () => { + const providers = await listProviders() + + const parsed = responseSchema.safeParse({ data: providers, nextCursor: null }) + expect(parsed.success ? [] : parsed.error.issues).toEqual([]) + expect( + providers.some( + (provider) => + provider.type === 'service_account' && + provider.providerId === GITHUB_INSTALLATION_PROVIDER_ID + ) + ).toBe(false) + }) + + it('keeps the field-less GitHub installation in the internal catalog for existing credentials', async () => { + const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), context) + const installation = catalog.find( + (provider) => + provider.type === 'service_account' && + provider.providerId === GITHUB_INSTALLATION_PROVIDER_ID + ) + + expect(installation?.fields).toEqual([]) + expect(responseSchema.safeParse({ data: catalog, nextCursor: null }).success).toBe(false) + }) + + it('lists the native Claude Platform provider when filtered', async () => { + const providers = await listProviders('claude') + + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + expect(providers).toContainEqual( + expect.objectContaining({ + type: 'service_account', + serviceId: CLAUDE_PROVIDER_ID, + providerId: CLAUDE_PROVIDER_ID, + available: true, + requiresClientGeneratedCredentialId: false, + fields: [expect.objectContaining({ id: 'apiToken', required: true, secret: true })], + }) + ) + }) + + it('reports Claude as unavailable, not missing, when integration policy disables it', async () => { + hoisted.allowedIntegrationTypes.mockResolvedValue(new Set(['slack'])) + + const providers = await listProviders() + + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + expect(providers).toContainEqual( + expect.objectContaining({ providerId: CLAUDE_PROVIDER_ID, available: false }) + ) + }) +}) diff --git a/packages/sim-cli/src/commands/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts index 562194f9d03..9d40f11cf0c 100644 --- a/packages/sim-cli/src/commands/credentials.test.ts +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -112,6 +112,61 @@ describe('credential connection commands', () => { ).rejects.toThrow('unsupported field "extra" for zoom-service-account') expect(mockRequest).toHaveBeenCalledTimes(1) }) + + describe('native Claude Platform service account', () => { + const claude = { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + available: true, + requiresClientGeneratedCredentialId: false, + fields: [{ id: 'apiToken', required: true, secret: true }], + } + const createArgs = [ + 'node', + 'sim', + 'credentials', + 'create', + 'claude-platform-service-account', + '--name', + 'Code Fixes', + '--credentials', + '{"apiToken":"test-api-token"}', + ] + + it('creates from the unfiltered catalog without requiring a client credential id', async () => { + mockRequest + .mockReset() + .mockResolvedValueOnce({ data: [claude], nextCursor: null }) + .mockResolvedValueOnce({ data: { id: 'credential-1' } }) + + await program().parseAsync(createArgs) + + expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/v2/credentials/providers', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) + const [, createRequest] = mockRequest.mock.calls[1] + expect(createRequest.body).toEqual({ + workspaceId: 'ws_local', + type: 'service_account', + providerId: 'claude-platform-service-account', + displayName: 'Code Fixes', + credentials: '{"apiToken":"test-api-token"}', + }) + }) + + it('refuses before creation when workspace policy disables the provider', async () => { + mockRequest + .mockReset() + .mockResolvedValueOnce({ data: [{ ...claude, available: false }], nextCursor: null }) + + await expect(program().parseAsync(createArgs)).rejects.toThrow( + 'Service-account provider "claude-platform-service-account" is not available.' + ) + expect(mockRequest).toHaveBeenCalledTimes(1) + }) + }) }) describe('credentials update --name', () => { From 844d1814d130bbab46e9183fd7476f75d64b907e Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 15:27:59 -0700 Subject: [PATCH 12/68] fix(executor): fail a stop-after run whose routing skips the stop block (#8635) * fix(executor): fail a stop-after run whose routing skips the stop block A run with stopAfterBlockId only stopped when the stop block completed. When a router, condition, or untaken error path routed the run away from it, the stop never triggered and the run finished every other branch, reporting success as if it had stopped there. A static check before the run cannot see this. - The engine ends the run as soon as every path into the stop block has been deactivated, before any further block starts, and fails it with `Stop block "" () was not reached: no path this run took leads to it`. - Any run that ends without completing its stop block fails the same way: a stop block missing from the executed graph, or a Response block that ended the run first. - A loop or parallel stop with nothing to run completes at its start sentinel, whose end sentinel never runs, so that exit now counts as reaching it. - The v2 contract and the CLI `--stop-after` help describe the failure. - E2E: a condition fixture checks the stop on the taken branch still stops there, a stop on the skipped branch fails the run before the other branch's slow block finishes, and the CLI exits non-zero. * fix(executor): a skipped stop block fails a run another branch paused, and names a Response ending - A run whose stop block was proven unreachable fails even when another branch paused, instead of returning a paused run that would resume past it. - When a Response block ended the run first, the error says so rather than claiming no path leads to the stop block. --- apps/docs/content/docs/cli/reference.mdx | 2 +- apps/docs/content/docs/cli/workflows.mdx | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/executor/execution/edge-manager.ts | 11 + apps/sim/executor/execution/engine.test.ts | 317 +++++++++++++++++- apps/sim/executor/execution/engine.ts | 86 ++++- apps/sim/lib/api/contracts/v2/workflows.ts | 2 +- .../scripts/test-workflow-stop-after-e2e.ts | 139 +++++++- .../commands/protocol/workflow-run-follow.ts | 2 +- 9 files changed, 533 insertions(+), 30 deletions(-) diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 80cadfee795..9655f81a3ad 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -6704,7 +6704,7 @@ sim workflows run [options] | `--mock-payload` | No | Use the selected trigger's server-derived mock payload; runs the current saved workflow state (implies --manual). | | `--from-block ` | No | Run manually from this saved workflow block. | | `--source-run ` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). | -| `--stop-after ` | No | Stop the run after this saved block; with --from-block on the same block, re-runs only that block (implies --manual). | +| `--stop-after ` | No | Stop the run after this saved block, failing it if the run takes a path that skips the block; with --from-block on the same block, re-runs only that block (implies --manual). | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | | `--include-thinking` | No | Show model reasoning while following (requires --follow). | | `--include-tool-calls` | No | Show tool calls while following (requires --follow). | diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index dc239167f2f..4d03ddb8c55 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -642,7 +642,7 @@ sim workflows run [options] | `--mock-payload` | No | Use the selected trigger's server-derived mock payload; runs the current saved workflow state (implies --manual). | | `--from-block ` | No | Run manually from this saved workflow block. | | `--source-run ` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). | -| `--stop-after ` | No | Stop the run after this saved block; with --from-block on the same block, re-runs only that block (implies --manual). | +| `--stop-after ` | No | Stop the run after this saved block, failing it if the run takes a path that skips the block; with --from-block on the same block, re-runs only that block (implies --manual). | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | | `--include-thinking` | No | Show model reasoning while following (requires --follow). | | `--include-tool-calls` | No | Show tool calls while following (requires --follow). | diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 2d34a84cd65..7625f7f1e0c 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -12617,7 +12617,7 @@ ] }, "stopAfterBlockId": { - "description": "Saved workflow block after which the run stops; downstream blocks do not execute. Must not be inside a loop or parallel. With a block entry naming the same block, re-runs only that block against the source run.", + "description": "Saved workflow block after which the run stops; downstream blocks do not execute. Must not be inside a loop or parallel. If a router, condition, or untaken error path routes the run away from the block, the run fails as soon as that is decided, without running the other branches. With a block entry naming the same block, re-runs only that block against the source run.", "type": "string", "minLength": 1 } diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index c190beafc6d..46aff1a3f34 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -122,6 +122,17 @@ export class EdgeManager { return node.incomingEdges.size === 0 || this.countActiveIncomingEdges(node) === 0 } + /** + * Whether a node that has not been queued can still run: it has received an activated edge, or + * an incoming edge is still undecided. False means every path into it was deactivated (a router + * or condition chose another route, or an error path was not taken), so nothing will queue it. + */ + canNodeStillRun(nodeId: string): boolean { + if (this.nodesWithActivatedEdge.has(nodeId)) return true + const node = this.dag.nodes.get(nodeId) + return node !== undefined && this.countActiveIncomingEdges(node) > 0 + } + restoreIncomingEdge(targetNodeId: string, sourceNodeId: string): void { const targetNode = this.dag.nodes.get(targetNodeId) if (!targetNode) { diff --git a/apps/sim/executor/execution/engine.test.ts b/apps/sim/executor/execution/engine.test.ts index b36e88bffcf..be1d8307f96 100644 --- a/apps/sim/executor/execution/engine.test.ts +++ b/apps/sim/executor/execution/engine.test.ts @@ -21,13 +21,17 @@ vi.mock('@/lib/execution/cancellation', () => ({ }, })) -import { EDGE } from '@/executor/constants' -import type { DAG, DAGNode } from '@/executor/dag/builder' -import type { EdgeManager } from '@/executor/execution/edge-manager' +import { BlockType, EDGE } from '@/executor/constants' +import { type DAG, DAGBuilder, type DAGNode } from '@/executor/dag/builder' +import { EdgeManager } from '@/executor/execution/edge-manager' import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import type { ExecutionContext, ExecutionResult } from '@/executor/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -import type { SerializedBlock } from '@/serializer/types' +import { + buildLoopSentinelEndId, + buildLoopSentinelStartId, +} from '@/executor/utils/subflow-node-id-codec' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { ExecutionEngine } from './engine' const executionEngineLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex( @@ -1088,4 +1092,309 @@ describe('ExecutionEngine', () => { expect(result.output).toEqual({ data: { response: true }, status: 200, headers: {} }) }) }) + /** + * A stop-after run is a promise that the run ends with the stop block. These run the real DAG + * builder and edge manager, so a router or condition deciding a path is the same decision the + * engine makes in production. + */ + describe('Stop-after block', () => { + function block(id: string, type = BlockType.FUNCTION): SerializedBlock { + return { ...createMockBlock(id), metadata: { id: type, name: id } } + } + + function buildRun( + workflow: SerializedWorkflow, + stopAfterBlockId: string, + outputs: Record = {} + ) { + const dag = new DAGBuilder().build(workflow, { triggerBlockId: 'start' }) + const executed: string[] = [] + const nodeOrchestrator = { + executeNode: vi.fn(async (_ctx: ExecutionContext, nodeId: string) => { + executed.push(nodeId) + return { nodeId, output: outputs[nodeId] ?? {}, isFinalOutput: false } + }), + handleNodeCompletion: vi.fn(), + } as unknown as NodeExecutionOrchestrator + const engine = new ExecutionEngine( + createMockContext({ + stopAfterBlockId, + decisions: { router: new Map(), condition: new Map() }, + }), + dag, + new EdgeManager(dag), + nodeOrchestrator + ) + return { engine, executed } + } + + /** start → condition; `if` → taken → takenTail, `else` → stop → after. */ + const conditionWorkflow: SerializedWorkflow = { + version: '1', + blocks: [ + block('start', BlockType.STARTER), + block('condition', BlockType.CONDITION), + block('taken'), + block('takenTail'), + block('stop'), + block('after'), + ], + connections: [ + { source: 'start', target: 'condition' }, + { source: 'condition', target: 'taken', sourceHandle: 'condition-if' }, + { source: 'condition', target: 'stop', sourceHandle: 'condition-else' }, + { source: 'taken', target: 'takenTail' }, + { source: 'stop', target: 'after' }, + ], + loops: {}, + parallels: {}, + } + + it('fails as soon as a condition routes away from the stop block', async () => { + const { engine, executed } = buildRun(conditionWorkflow, 'stop', { + condition: { selectedOption: 'if' }, + }) + + await expect(engine.run('start')).rejects.toThrow('Stop block "stop" (stop) was not reached') + expect(executed).toEqual(['start', 'condition']) + }) + + it('fails as soon as a router routes away from the stop block', async () => { + const { engine, executed } = buildRun( + { + version: '1', + blocks: [ + block('start', BlockType.STARTER), + block('router', BlockType.ROUTER_V2), + block('taken'), + block('takenTail'), + block('stop'), + ], + connections: [ + { source: 'start', target: 'router' }, + { source: 'router', target: 'taken', sourceHandle: 'router-route-a' }, + { source: 'router', target: 'stop', sourceHandle: 'router-route-b' }, + { source: 'taken', target: 'takenTail' }, + ], + loops: {}, + parallels: {}, + }, + 'stop', + { router: { selectedRoute: 'route-a' } } + ) + + await expect(engine.run('start')).rejects.toThrow('Stop block "stop" (stop) was not reached') + expect(executed).toEqual(['start', 'router']) + }) + + it('fails rather than pausing when another branch pauses after the stop block is skipped', async () => { + const { engine, executed } = buildRun( + { + version: '1', + blocks: [ + block('start', BlockType.STARTER), + block('approval'), + block('condition', BlockType.CONDITION), + block('taken'), + block('stop'), + ], + connections: [ + { source: 'start', target: 'approval' }, + { source: 'start', target: 'condition' }, + { source: 'condition', target: 'taken', sourceHandle: 'condition-if' }, + { source: 'condition', target: 'stop', sourceHandle: 'condition-else' }, + ], + loops: {}, + parallels: {}, + }, + 'stop', + { + approval: { + response: { status: 'paused' }, + _pauseMetadata: { + contextId: 'pause-1', + blockId: 'approval', + response: { status: 'paused' }, + timestamp: new Date().toISOString(), + pauseKind: 'hitl', + }, + }, + condition: { selectedOption: 'if' }, + } + ) + + await expect(engine.run('start')).rejects.toThrow('Stop block "stop" (stop) was not reached') + expect(executed).toEqual(['start', 'approval', 'condition']) + }) + + it('fails when the stop block sits on an error path the run never takes', async () => { + const { engine, executed } = buildRun( + { + version: '1', + blocks: [block('start', BlockType.STARTER), block('work'), block('next'), block('stop')], + connections: [ + { source: 'start', target: 'work' }, + { source: 'work', target: 'next', sourceHandle: 'source' }, + { source: 'work', target: 'stop', sourceHandle: 'error' }, + ], + loops: {}, + parallels: {}, + }, + 'stop' + ) + + await expect(engine.run('start')).rejects.toThrow('Stop block "stop" (stop) was not reached') + expect(executed).toEqual(['start', 'work']) + }) + + it('stops after the stop block when the condition routes to it', async () => { + const { engine, executed } = buildRun(conditionWorkflow, 'stop', { + condition: { selectedOption: 'else' }, + }) + + const result = await engine.run('start') + + expect(result.success).toBe(true) + expect(executed).toEqual(['start', 'condition', 'stop']) + }) + + it('runs a join reached through one taken and one skipped branch', async () => { + const { engine, executed } = buildRun( + { + version: '1', + blocks: [ + block('start', BlockType.STARTER), + block('condition', BlockType.CONDITION), + block('taken'), + block('skipped'), + block('stop'), + block('after'), + ], + connections: [ + { source: 'start', target: 'condition' }, + { source: 'condition', target: 'taken', sourceHandle: 'condition-if' }, + { source: 'condition', target: 'skipped', sourceHandle: 'condition-else' }, + { source: 'taken', target: 'stop' }, + { source: 'skipped', target: 'stop' }, + { source: 'stop', target: 'after' }, + ], + loops: {}, + parallels: {}, + }, + 'stop', + { condition: { selectedOption: 'if' } } + ) + + const result = await engine.run('start') + + expect(result.success).toBe(true) + expect(executed).toEqual(['start', 'condition', 'taken', 'stop']) + }) + + it('runs a loop stop block queued by a dead end inside the loop', async () => { + const sentinelStart = buildLoopSentinelStartId('loop') + const sentinelEnd = buildLoopSentinelEndId('loop') + const { engine, executed } = buildRun( + { + version: '1', + blocks: [ + block('start', BlockType.STARTER), + block('loop', BlockType.LOOP), + block('condition', BlockType.CONDITION), + block('inner'), + block('after'), + ], + connections: [ + { source: 'start', target: 'loop' }, + { source: 'loop', target: 'condition', sourceHandle: 'loop-start-source' }, + { source: 'condition', target: 'inner', sourceHandle: 'condition-if' }, + { source: 'loop', target: 'after', sourceHandle: 'loop-end-source' }, + ], + loops: { loop: { id: 'loop', nodes: ['condition', 'inner'], iterations: 1 } }, + parallels: {}, + }, + sentinelEnd, + { condition: { selectedOption: 'else' } } + ) + + const result = await engine.run('start') + + expect(result.success).toBe(true) + expect(executed).toEqual(['start', sentinelStart, 'condition', sentinelEnd]) + }) + + it('stops after a loop stop block whose loop has nothing to run', async () => { + const sentinelStart = buildLoopSentinelStartId('loop') + const { engine, executed } = buildRun( + { + version: '1', + blocks: [ + block('start', BlockType.STARTER), + block('loop', BlockType.LOOP), + block('inner'), + block('after'), + ], + connections: [ + { source: 'start', target: 'loop' }, + { source: 'loop', target: 'inner', sourceHandle: 'loop-start-source' }, + { source: 'loop', target: 'after', sourceHandle: 'loop-end-source' }, + ], + loops: { loop: { id: 'loop', nodes: ['inner'], iterations: 0 } }, + parallels: {}, + }, + buildLoopSentinelEndId('loop'), + { + [sentinelStart]: { sentinelStart: true, shouldExit: true, selectedRoute: EDGE.LOOP_EXIT }, + } + ) + + const result = await engine.run('start') + + expect(result.success).toBe(true) + expect(executed).toEqual(['start', sentinelStart]) + }) + + it('succeeds when the stop block is a Response block, and fails when one ends the run first', async () => { + const workflow: SerializedWorkflow = { + version: '1', + blocks: [ + block('start', BlockType.STARTER), + block('respond', BlockType.RESPONSE), + block('stop'), + ], + connections: [ + { source: 'start', target: 'respond' }, + { source: 'respond', target: 'stop' }, + ], + loops: {}, + parallels: {}, + } + + const atResponse = buildRun(workflow, 'respond') + expect((await atResponse.engine.run('start')).success).toBe(true) + + const pastResponse = buildRun(workflow, 'stop') + await expect(pastResponse.engine.run('start')).rejects.toThrow( + 'Stop block "stop" (stop) was not reached: a Response block ended the run first' + ) + expect(pastResponse.executed).toEqual(['start', 'respond']) + }) + + it('stops after the entry block when the stop block is the entry', async () => { + const { engine, executed } = buildRun(conditionWorkflow, 'start') + + const result = await engine.run('start') + + expect(result.success).toBe(true) + expect(executed).toEqual(['start']) + }) + + it('fails when the stop block is not in the graph the run executes', async () => { + const { engine } = buildRun(conditionWorkflow, 'missing', { + condition: { selectedOption: 'if' }, + }) + + await expect(engine.run('start')).rejects.toThrow('Stop block missing was not reached') + }) + }) }) diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 86f696694cd..a2c0aebcc83 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { combineExecutionAbortSignals } from '@/lib/core/execution-limits' import { subscribeToExecutionCancellation } from '@/lib/execution/cancellation' import { BlockType, EDGE } from '@/executor/constants' -import type { DAG } from '@/executor/dag/builder' +import type { DAG, DAGNode } from '@/executor/dag/builder' import type { EdgeManager } from '@/executor/execution/edge-manager' import { buildCompletedExecutionState, @@ -35,6 +35,9 @@ export class ExecutionEngine { private cancelledFlag = false private errorFlag = false private stoppedEarlyFlag = false + private stopBlockQueued = false + private stopBlockReached = false + private stopBlockUnreachable = false private executionError: Error | null = null private abortPromise!: Promise private abortResolve!: () => void @@ -125,6 +128,11 @@ export class ExecutionEngine { throw this.executionError } + /** A pause keeps a run whose stop block can still run; one proven unreachable fails. */ + if (!this.cancelledFlag && (this.stopBlockUnreachable || this.pausedBlocks.size === 0)) { + this.assertStopBlockReached() + } + if (this.pausedBlocks.size > 0) { return this.buildPausedResult(startTime) } @@ -228,6 +236,9 @@ export class ExecutionEngine { if (!this.readyQueue.includes(nodeId)) { this.readyQueue.push(nodeId) } + if (nodeId === this.context.stopAfterBlockId) { + this.stopBlockQueued = true + } } private addMultipleToQueue(nodeIds: string[]): void { @@ -483,6 +494,9 @@ export class ExecutionEngine { this.setFinalOutput(nodeId, output) this.responseOutputLocked = true } + if (this.context.stopAfterBlockId === nodeId) { + this.stopBlockReached = true + } this.stoppedEarlyFlag = true return } @@ -491,21 +505,71 @@ export class ExecutionEngine { this.setFinalOutput(nodeId, output) } - if (this.context.stopAfterBlockId === nodeId) { - // For loop/parallel sentinels, only stop if the subflow has fully exited (all iterations done) - // shouldContinue: true means more iterations, shouldExit: true means loop is done - const shouldContinue = - output.shouldContinue === true || output.selectedRoute === EDGE.PARALLEL_CONTINUE - if (!shouldContinue) { - this.execLogger.info('Stopping execution after target block', { nodeId }) - this.stoppedEarlyFlag = true - return - } + if (this.completesStopBlock(node, output)) { + this.execLogger.info('Stopping execution after target block', { nodeId }) + this.stopBlockReached = true + this.stoppedEarlyFlag = true + return } const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false) this.addMultipleToQueue(readyNodes) + this.stopIfStopBlockCannotRun() + } + + /** + * Whether this completion finishes the stop block. A loop or parallel stop resolves to its end + * sentinel, which finishes only once no iteration remains; a subflow with nothing to run exits + * from its start sentinel, and its end sentinel never runs. + */ + private completesStopBlock(node: DAGNode, output: NormalizedBlockOutput): boolean { + const stopBlockId = this.context.stopAfterBlockId + if (!stopBlockId) return false + if (node.id === stopBlockId) { + return output.shouldContinue !== true && output.selectedRoute !== EDGE.PARALLEL_CONTINUE + } + const stopNode = this.dag.nodes.get(stopBlockId) + return ( + (output.selectedRoute === EDGE.LOOP_EXIT || output.selectedRoute === EDGE.PARALLEL_EXIT) && + node.metadata.sentinelType === 'start' && + stopNode?.metadata.sentinelType === 'end' && + node.metadata.subflowId === stopNode.metadata.subflowId + ) + } + + /** + * Ends the run once its stop block can no longer execute, rather than running every other branch + * to completion first; {@link assertStopBlockReached} then fails it. + */ + private stopIfStopBlockCannotRun(): void { + const stopBlockId = this.context.stopAfterBlockId + if (!stopBlockId || this.stopBlockQueued || this.edgeManager.canNodeStillRun(stopBlockId)) { + return + } + this.execLogger.info('Stopping execution: the stop block can no longer run', { stopBlockId }) + this.stopBlockUnreachable = true + this.stoppedEarlyFlag = true + } + + /** + * A stop-after run succeeds only by completing its stop block. A run whose routing skipped it, + * or that a Response block ended first, fails instead of passing for a run that stopped there. + */ + private assertStopBlockReached(): void { + const stopBlockId = this.context.stopAfterBlockId + if (!stopBlockId || this.stopBlockReached) return + const node = this.dag.nodes.get(stopBlockId) + const label = node?.metadata.isSentinel + ? (node.metadata.subflowId ?? stopBlockId) + : node?.block.metadata?.name + ? `"${node.block.metadata.name}" (${stopBlockId})` + : stopBlockId + const reason = + this.responseOutputLocked && !this.stopBlockUnreachable + ? 'a Response block ended the run first' + : 'no path this run took leads to it' + throw new Error(`Stop block ${label} was not reached: ${reason}`) } private setFinalOutput(nodeId: string, output: NormalizedBlockOutput): void { diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 917d935556f..2ab8cf6d46b 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1302,7 +1302,7 @@ export const v2WorkflowRunSelectionSchema = z.discriminatedUnion('source', [ .min(1, 'run.stopAfterBlockId cannot be empty') .optional() .describe( - 'Saved workflow block after which the run stops; downstream blocks do not execute. Must not be inside a loop or parallel. With a block entry naming the same block, re-runs only that block against the source run.' + 'Saved workflow block after which the run stops; downstream blocks do not execute. Must not be inside a loop or parallel. If a router, condition, or untaken error path routes the run away from the block, the run fails as soon as that is decided, without running the other branches. With a block entry naming the same block, re-runs only that block against the source run.' ), }) .strict(), diff --git a/apps/sim/scripts/test-workflow-stop-after-e2e.ts b/apps/sim/scripts/test-workflow-stop-after-e2e.ts index 691583fa980..c27ab483826 100644 --- a/apps/sim/scripts/test-workflow-stop-after-e2e.ts +++ b/apps/sim/scripts/test-workflow-stop-after-e2e.ts @@ -29,6 +29,10 @@ import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' * in for an expensive upstream block, so a single-block re-run of Check that * finishes well under Slow's delay proves Slow was not re-executed, and an * absent After output proves the run stopped where it was told to. + * + * A second fixture branches: `Start → Gate (condition, always if)`, with `if → + * Taken → Tail (slow wait)` and `else → Skipped`. A stop on Skipped must fail the + * run once Gate routes away from it, well before Tail's delay would elapse. */ const logger = createLogger('WorkflowStopAfterE2E') const execFileAsync = promisify(execFile) @@ -76,6 +80,14 @@ interface PipelineFixture { const pipeline = fixtureIds() const otherPipeline = fixtureIds() +const branch = { + workflowId: generateId(), + start: generateId(), + gate: generateId(), + taken: generateId(), + tail: generateId(), + skipped: generateId(), +} function fixtureIds(): PipelineFixture { return { @@ -116,11 +128,6 @@ function record(value: unknown): Record { async function seedPipeline(tx: postgres.TransactionSql, fixture: PipelineFixture) { await tx`insert into workflow (id, user_id, workspace_id, name, last_synced, created_at, updated_at) values (${fixture.workflowId}, ${ownerId}, ${workspaceId}, ${`Stop-after fixture ${fixture.workflowId}`}, now(), now(), now())` - const wait = (seconds: number) => ({ - timeValue: { id: 'timeValue', type: 'short-input', value: String(seconds) }, - timeUnit: { id: 'timeUnit', type: 'dropdown', value: 'seconds' }, - async: { id: 'async', type: 'switch', value: false }, - }) const blocks = [ { id: fixture.start, @@ -128,9 +135,9 @@ async function seedPipeline(tx: postgres.TransactionSql, fixture: PipelineFixtur name: 'Start', subBlocks: { inputFormat: { id: 'inputFormat', type: 'input-format', value: [] } }, }, - { id: fixture.slow, type: 'wait', name: 'Slow', subBlocks: wait(SLOW_SECONDS) }, - { id: fixture.check, type: 'wait', name: 'Check', subBlocks: wait(0.2) }, - { id: fixture.after, type: 'wait', name: 'After', subBlocks: wait(0.2) }, + { id: fixture.slow, type: 'wait', name: 'Slow', subBlocks: waitSubBlocks(SLOW_SECONDS) }, + { id: fixture.check, type: 'wait', name: 'Check', subBlocks: waitSubBlocks(0.2) }, + { id: fixture.after, type: 'wait', name: 'After', subBlocks: waitSubBlocks(0.2) }, ] for (const [index, block] of blocks.entries()) { await tx`insert into workflow_blocks (id, workflow_id, type, name, position_x, position_y, sub_blocks) @@ -146,6 +153,59 @@ async function seedPipeline(tx: postgres.TransactionSql, fixture: PipelineFixtur } } +function waitSubBlocks(seconds: number) { + return { + timeValue: { id: 'timeValue', type: 'short-input', value: String(seconds) }, + timeUnit: { id: 'timeUnit', type: 'dropdown', value: 'seconds' }, + async: { id: 'async', type: 'switch', value: false }, + } +} + +async function seedBranch(tx: postgres.TransactionSql) { + await tx`insert into workflow (id, user_id, workspace_id, name, last_synced, created_at, updated_at) + values (${branch.workflowId}, ${ownerId}, ${workspaceId}, ${`Stop-after branch fixture ${branch.workflowId}`}, now(), now(), now())` + const conditions = [ + { id: `${branch.gate}-if`, title: 'if', value: 'true' }, + { id: `${branch.gate}-else`, title: 'else', value: '' }, + ] + const blocks = [ + { + id: branch.start, + type: 'start_trigger', + name: 'Start', + subBlocks: { inputFormat: { id: 'inputFormat', type: 'input-format', value: [] } }, + }, + { + id: branch.gate, + type: 'condition', + name: 'Gate', + subBlocks: { + conditions: { + id: 'conditions', + type: 'condition-input', + value: JSON.stringify(conditions), + }, + }, + }, + { id: branch.taken, type: 'wait', name: 'Taken', subBlocks: waitSubBlocks(0.2) }, + { id: branch.tail, type: 'wait', name: 'Tail', subBlocks: waitSubBlocks(SLOW_SECONDS) }, + { id: branch.skipped, type: 'wait', name: 'Skipped', subBlocks: waitSubBlocks(0.2) }, + ] + for (const [index, block] of blocks.entries()) { + await tx`insert into workflow_blocks (id, workflow_id, type, name, position_x, position_y, sub_blocks) + values (${block.id}, ${branch.workflowId}, ${block.type}, ${block.name}, ${index * 300}, 0, ${JSON.stringify(block.subBlocks)}::text::jsonb)` + } + for (const [source, target, sourceHandle] of [ + [branch.start, branch.gate, 'source'], + [branch.gate, branch.taken, `condition-${branch.gate}-if`], + [branch.gate, branch.skipped, `condition-${branch.gate}-else`], + [branch.taken, branch.tail, 'source'], + ]) { + await tx`insert into workflow_edges (id, workflow_id, source_block_id, target_block_id, source_handle, target_handle) + values (${generateId()}, ${branch.workflowId}, ${source}, ${target}, ${sourceHandle}, 'target')` + } +} + async function seed() { directory = await mkdtemp(resolve(tmpdir(), 'sim-stop-after-')) await sql.begin(async (tx) => { @@ -161,6 +221,7 @@ async function seed() { values (${generateId()}, ${ownerId}, 'Stop-after fixture', ${personalKey}, ${sha256Hex(personalKey)}, 'personal')` await seedPipeline(tx, pipeline) await seedPipeline(tx, otherPipeline) + await seedBranch(tx) }) } @@ -240,10 +301,22 @@ async function runCli(args: string[]): Promise { return v2ExecuteWorkflowDataSchema.parse(JSON.parse(stdout)) } +/** A CLI run the command itself must fail: exits non-zero and prints the failed run. */ +async function runCliExpectingFailure(args: string[]): Promise { + try { + await runCli(args) + } catch (error) { + assert(isRecordLike(error) && typeof error.stdout === 'string', getErrorMessage(error)) + assert.notEqual(error.code, 0, 'a failed run must exit non-zero') + return v2ExecuteWorkflowDataSchema.parse(JSON.parse(error.stdout)) + } + assert.fail('the CLI exited 0 for a run that must fail') +} + const selectAll = ['Slow.status', 'Check.status', 'After.status'] try { - await check('seed disposable workspace, personal key and two pipelines', seed) + await check('seed disposable workspace, personal key and fixtures', seed) let sourceRunId = '' await check('a full manual run executes every block and persists its state', async () => { @@ -294,6 +367,52 @@ try { assert.deepEqual(until.blockOutputs, { 'Slow.status': 'completed' }) }) + const branchOutputs = ['Taken.status', 'Tail.status', 'Skipped.status'] + + await check( + 'a stop block on the branch the condition takes still stops the run there', + async () => { + const until = await run(branch.workflowId, { + run: { source: 'manual', stopAfterBlockId: branch.taken }, + selectedOutputs: branchOutputs, + }) + assert.deepEqual(until.blockOutputs, { 'Taken.status': 'completed' }) + } + ) + + await check( + 'a stop block the condition routes away from fails the run before the other branch finishes', + async () => { + const skipped = v2ExecuteWorkflowDataSchema.parse( + record( + await execute(branch.workflowId, { + run: { source: 'manual', stopAfterBlockId: branch.skipped }, + selectedOutputs: branchOutputs, + }) + ).data + ) + assert.equal(skipped.status, 'failed') + assert.match(skipped.error?.message ?? '', /Stop block "Skipped" \(.+\) was not reached/) + assert.equal(skipped.blockOutputs?.['Skipped.status'], undefined) + assert.equal(skipped.blockOutputs?.['Tail.status'], undefined) + assert( + (skipped.durationMs ?? Number.POSITIVE_INFINITY) < SLOW_MS, + `the run must end once Gate decides, not after Tail's ${SLOW_MS} ms, took ${skipped.durationMs} ms` + ) + } + ) + + await check('the CLI exits non-zero when the stop block is not reached', async () => { + const skipped = await runCliExpectingFailure([ + branch.workflowId, + '--stop-after', + branch.skipped, + ...branchOutputs.flatMap((selector) => ['--select-output', selector]), + ]) + assert.equal(skipped.status, 'failed') + assert.match(skipped.error?.message ?? '', /was not reached/) + }) + await check('a block entry without stopAfterBlockId still runs downstream blocks', async () => { const fromCheck = await run(pipeline.workflowId, { run: { @@ -375,7 +494,7 @@ try { await check('remove disposable fixtures', async () => { // A response returns before its run finishes persisting logs and large-value // references; a cascade delete racing those writes can be chosen as a deadlock victim. - const workflowIds = [pipeline.workflowId, otherPipeline.workflowId] + const workflowIds = [pipeline.workflowId, otherPipeline.workflowId, branch.workflowId] for (let attempt = 0; attempt < 120; attempt++) { const [{ open }] = await sql`select count(*)::int as open from workflow_execution_logs where workflow_id in ${sql(workflowIds)} and ended_at is null` diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index 00f8ca36807..307b47ad01a 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -495,7 +495,7 @@ export function attachWorkflowRunFollow(workflows: Command): void { ) .option( '--stop-after ', - 'Stop the run after this saved block; with --from-block on the same block, re-runs only that block (implies --manual)' + 'Stop the run after this saved block, failing it if the run takes a path that skips the block; with --from-block on the same block, re-runs only that block (implies --manual)' ) .option( '--follow', From 21c8a072d7629492eebea40a3424dd56294c9865 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 15:48:58 -0700 Subject: [PATCH 13/68] chore(terms): update terms of service (#8636) --- .../sim/app/(landing)/terms/terms-content.tsx | 359 ++++++++++++++---- 1 file changed, 277 insertions(+), 82 deletions(-) diff --git a/apps/sim/app/(landing)/terms/terms-content.tsx b/apps/sim/app/(landing)/terms/terms-content.tsx index 7f8520214f2..2d2572364b0 100644 --- a/apps/sim/app/(landing)/terms/terms-content.tsx +++ b/apps/sim/app/(landing)/terms/terms-content.tsx @@ -1,24 +1,21 @@ import { type LegalPageConfig, ProseLink } from '@/app/(landing)/components/prose-page' -/** - * Terms of Service content - the verbatim legal text, expressed as the typed - * {@link LegalPageConfig} that {@link ProsePage} renders. The text is ported - * unchanged from the prior Terms document; only the layout and inline-link - * chrome are re-authored onto the landing primitives. - */ +/** Terms of Service content rendered by the shared legal-page layout. */ export const TERMS_CONFIG: LegalPageConfig = { title: 'Terms of Service', description: 'The terms and conditions for using Sim, the open-source AI workspace: subscription plans, data ownership, and acceptable use.', - lastUpdated: 'October 11, 2025', + lastUpdated: 'October 5, 2026', intro: [ { kind: 'paragraph', - content: `Please read these Terms of Service ("Terms") carefully before using the Sim platform (the "Service") operated by Sim, Inc ("us", "we", or "our").`, + content: + 'Please read these Terms of Service ("Terms") carefully before using the Sim-hosted platform and related hosted features (the "Service") operated by Sim Studio, Inc. ("us", "we", or "our").', }, { kind: 'paragraph', - content: `By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the terms, you may not access the Service.`, + content: + 'By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the terms, you may not access the Service.', }, ], sections: [ @@ -28,15 +25,33 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `When you create an account with us, you must provide accurate, complete, and current information. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of your account on our Service.`, + content: + 'Subject to the existing-account transition in Section 16, you must be at least 18 years old, meet any higher minimum age required by applicable law, and have legal capacity to enter into these Terms to create an account for or use Sim-hosted services. By creating an account or using Sim-hosted services, you represent and warrant that you meet these requirements. Individuals under 18 are not permitted to create an account or use Sim-hosted services, even with permission from a parent or guardian.', }, { kind: 'paragraph', - content: `You are responsible for safeguarding the password that you use to access the Service and for any activities or actions under your password.`, + content: + 'If you use the Service on behalf of a company or other legal entity, you represent and warrant that you have authority to bind that entity to these Terms. In that case, references to "you" and "your" refer to that entity, and each individual using the Service on its behalf must meet the eligibility requirements above.', }, { kind: 'paragraph', - content: `You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware of any breach of security or unauthorized use of your account.`, + content: + 'These eligibility requirements apply to Sim-hosted accounts and services. They do not modify rights granted under any separate license applicable to Sim software that you self-host.', + }, + { + kind: 'paragraph', + content: + 'When you create an account with us, you must provide accurate, complete, and current information. Failure to do so constitutes a breach of these Terms and may result in suspension or termination under Section 10.', + }, + { + kind: 'paragraph', + content: + 'You are responsible for safeguarding the password that you use to access the Service and for any activities or actions under your password.', + }, + { + kind: 'paragraph', + content: + 'You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware of any breach of security or unauthorized use of your account.', }, ], }, @@ -46,11 +61,23 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `Subject to your compliance with these Terms, we grant you a limited, non-exclusive, non-transferable, revocable license to access and use the Service for your internal business or personal purposes.`, + content: + 'Subject to these Terms, your subscription plan and any separate written agreement with us, we grant you a limited, non-exclusive, non-transferable license to access and use the Service for your business or personal purposes. This includes building and operating workflows, integrations and applications for your customers or other users through sharing, API, chat and embedding features made available under your plan and described in our documentation.', + }, + { + kind: 'paragraph', + content: + 'You are responsible for your applications and workflows, for having authority to process the data and take the actions you instruct the Service to perform, and for providing notices and obtaining permissions required by applicable law. Allowing others to access a Sim-hosted deployment does not waive the eligibility requirements in Section 1 or the acceptable-use requirements in Section 9.', + }, + { + kind: 'paragraph', + content: + 'You may not resell, sublicense or provide the hosted Sim platform itself as a standalone or white-label service without our written authorization, or circumvent account, seat or usage limits. This restriction does not prohibit the permitted customer-facing workflows and applications described above.', }, { kind: 'paragraph', - content: `This license does not permit you to resell, redistribute, or make the Service available to third parties, or to use the Service to build a competitive product or service.`, + content: + 'Software distributed under a separate open-source or other software license is governed by that license. These Terms do not reduce rights granted by that license; access to Sim-hosted services remains subject to these Terms.', }, ], }, @@ -60,15 +87,18 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `We offer Free, Pro, Max, and Enterprise subscription plans. Paid plans include a base subscription fee plus usage-based charges for inference and other services that exceed your plan's included limits.`, + content: + "We offer Free, Pro, Max, and Enterprise subscription plans. Paid plans include a base subscription fee plus usage-based charges for inference and other services that exceed your plan's included limits.", }, { kind: 'paragraph', - content: `You agree to pay all fees associated with your account. Your base subscription fee is charged at the beginning of each billing cycle (monthly or annually). Inference overages are charged incrementally every $50 during your billing period, which may result in multiple invoices within a single billing cycle. Payment is due upon receipt of invoice. If payment fails, we may suspend or terminate your access to paid features.`, + content: + 'You agree to pay all fees associated with your account. Your base subscription fee is charged at the beginning of each billing cycle (monthly or annually). Inference overages are charged incrementally every $50 during your billing period, which may result in multiple invoices within a single billing cycle. Payment is due upon receipt of invoice. If you fail to pay amounts properly due, we may suspend or terminate your access to paid features in accordance with Section 10.', }, { kind: 'paragraph', - content: `We reserve the right to change our pricing with 30 days' notice to paid subscribers. Price changes will take effect at your next renewal.`, + content: + "We may change pricing for a paid subscription on at least 30 days' notice. A price change takes effect at the first renewal occurring at least 30 days after that notice, unless a separate written agreement or applicable law provides otherwise.", }, ], }, @@ -78,15 +108,34 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `Paid subscriptions automatically renew at the end of each billing period unless you cancel before the renewal date. You can cancel your subscription at any time through your account settings or by contacting us.`, + content: ( + <> + { + 'Paid subscriptions automatically renew for successive billing periods of the length selected at purchase unless you cancel before the renewal date. You can cancel renewal through your account settings or by contacting us at ' + } + legal@sim.ai + {'.'} + + ), + }, + { + kind: 'paragraph', + content: + 'Cancellation of renewal takes effect at the end of the current billing period. Until then, you retain paid access unless you separately request earlier account closure or access is suspended or terminated under Section 10. Cancelling renewal does not, by itself, close your account or request deletion of Your Data. Simply stopping use does not cancel a paid subscription.', }, { kind: 'paragraph', - content: `Cancellations take effect at the end of the current billing period. You will retain access to paid features until that time. We do not provide refunds for partial billing periods.`, + content: + 'Except as required by applicable law, expressly agreed in writing, or provided in Section 10 for termination by us without your breach, we do not provide refunds for partial billing periods. Cancellation does not remove responsibility for usage charges or other fees properly incurred before cancellation takes effect.', }, { kind: 'paragraph', - content: `Upon cancellation or termination, you may export your data within 30 days. After 30 days, we may delete your data in accordance with our data retention policies.`, + content: + 'Nothing in these Terms excludes a statutory withdrawal, cancellation, refund or other remedy that cannot lawfully be excluded. Where a separate request or consent is legally required to start supplying a service or digital content during a withdrawal period, accepting these Terms alone does not constitute that request or consent or a waiver of the withdrawal right.', + }, + { + kind: 'paragraph', + content: 'Account closure, export and deletion are addressed in Sections 5 and 10.', }, ], }, @@ -96,11 +145,63 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `You retain all ownership rights to data, content, and information you submit to the Service ("Your Data"). You grant us a limited license to process, store, and transmit Your Data solely to provide and improve the Service as described in our Privacy Policy.`, + content: + 'You retain the ownership rights you have in the data, content and information you submit to the Service ("Your Data"). User Content described in Section 7 is part of Your Data. You grant us a limited, non-exclusive license to host, reproduce, process, transmit and display Your Data only as necessary to provide, maintain, secure and support the Service and carry out your instructions, including integrations, sharing and model calls you enable, or to comply with applicable law. You must have the rights and authority necessary for that processing.', }, { kind: 'paragraph', - content: `We retain Your Data while your account is active and for 30 days after account termination or cancellation. You may request data export or deletion at any time through your account settings.`, + content: ( + <> + {'Our handling of personal data is described in our '} + Privacy Policy + { + '. This license does not expand the processing permissions stated there, constitute consent where separate consent is required, or waive data protection rights. Information about use of the Service is handled for the purposes and on the legal bases described in that policy; the license above is not a general permission to reuse customer content for unrelated product development.' + } + + ), + }, + { + kind: 'paragraph', + content: + "Nothing in these Terms permits Sim, or providers processing Your Data on Sim's behalf, to use Your Data to train or improve generalized or shared AI models. Google API data remains subject to the Google Data Limited Use restrictions described in our Privacy Policy. Where you connect an independent provider using your own credentials, its processing is also governed by your arrangement with that provider; this does not reduce Sim's own obligations or authorize a use that applicable law or provider rules prohibit.", + }, + { + kind: 'paragraph', + content: + 'Where we process personal data on your behalf, that processing is subject to your documented instructions and any data processing addendum in force between us. An applicable data processing addendum controls a conflict concerning that processing. These Terms are not a substitute for a data processing agreement required by law.', + }, + { + kind: 'paragraph', + content: ( + <> + { + 'Retention and deletion. Retention periods and deletion triggers differ by data category, as described in our Privacy Policy and any applicable data processing addendum, subject to applicable law. Cancellation of subscription renewal is not an account-deletion request. You may request account closure or deletion separately as described in Section 10, and may make privacy or deletion requests at ' + } + privacy@sim.ai + { + '. We may reasonably verify your identity and authority before fulfilling a request.' + } + + ), + }, + { + kind: 'paragraph', + content: ( + <> + { + 'Post-closure export. For 30 days after account closure or termination, you may request an export of Your Data that remains in our possession by contacting ' + } + privacy@sim.ai + { + ', including where ordinary account access has ended. We will provide the export subject to reasonable identity and authority verification and applicable legal restrictions. This request window does not postpone an earlier deletion you request, a shorter applicable data-category retention period, deletion required by law, or deletion required by an applicable data processing addendum. Data already deleted under those rules cannot be exported. We recommend exporting data you need before requesting closure or deletion.' + } + + ), + }, + { + kind: 'paragraph', + content: + 'The export window is not a promise that every data category or copy is retained for 30 days or erased on the same day. Some records, such as transaction or security records, may have different justified retention periods under the Privacy Policy, an applicable data processing addendum and law. Backups and downstream-provider copies remain subject to applicable deletion obligations; their existence does not create an unrestricted right to retain or reuse Your Data.', }, ], }, @@ -110,11 +211,13 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `The Service and its original content, features, and functionality are and will remain the exclusive property of Sim, Inc and its licensors. The Service is protected by copyright, trademark, and other laws of both the United States and foreign countries.`, + content: + 'Except for Your Data, third-party materials and software governed by a separate license, the Service and its original content, features and functionality are the property of Sim Studio, Inc. or its licensors and are protected by copyright, trademark and other applicable laws. Rights in separately licensed software remain governed by its applicable license as described in Section 2.', }, { kind: 'paragraph', - content: `Our trademarks and trade dress may not be used in connection with any product or service without the prior written consent of Sim, Inc.`, + content: + 'Our trademarks and trade dress may not be used in connection with a product or service without the prior written consent of Sim Studio, Inc., except as permitted by applicable law.', }, ], }, @@ -124,22 +227,25 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `Our Service allows you to post, link, store, share and otherwise make available certain information, text, graphics, videos, or other material ("User Content"). You are responsible for the User Content that you post on or through the Service, including its legality, reliability, and appropriateness.`, + content: + 'Our Service allows you to post, link, store, share and otherwise make available certain information, text, graphics, videos, or other material ("User Content"). You are responsible for the User Content that you post on or through the Service, including its legality, reliability, and appropriateness.', }, { kind: 'paragraph', - content: `By posting User Content on or through the Service, you represent and warrant that:`, + content: + 'By posting User Content on or through the Service, you represent and warrant that:', }, { kind: 'list', items: [ - `The User Content is yours (you own it) or you have the right to use it and grant us the rights and license as provided in these Terms.`, - `The posting of your User Content on or through the Service does not violate the privacy rights, publicity rights, copyrights, contract rights or any other rights of any person.`, + 'The User Content is yours (you own it) or you have the right to use it and grant us the rights and license as provided in these Terms.', + 'The posting of your User Content on or through the Service does not violate the privacy rights, publicity rights, copyrights, contract rights or any other rights of any person.', ], }, { kind: 'paragraph', - content: `We reserve the right to terminate the account of any user found to be infringing on a copyright.`, + content: + 'We may suspend or terminate accounts for copyright infringement in accordance with Section 10 and applicable law.', }, ], }, @@ -149,11 +255,18 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `The Service may integrate with third-party services (such as Google Workspace, cloud storage providers, and AI model providers). Your use of third-party services is subject to their respective terms and privacy policies.`, + content: + 'The Service may integrate with third-party services, including Google Workspace, cloud storage and AI model providers. You are responsible for having authority to connect the accounts and services you choose and for complying with their applicable terms. Your use of an independent third-party service may be subject to its terms and privacy policy.', + }, + { + kind: 'paragraph', + content: + "We do not control independent third-party services and do not warrant their continued availability or functionality. This does not exclude responsibility that Sim has under applicable law, an applicable agreement, or its Privacy Policy for providers processing data on Sim's behalf. Third-party terms do not reduce your rights against Sim where those rights cannot lawfully be excluded.", }, { kind: 'paragraph', - content: `We are not responsible for the availability, functionality, or actions of third-party services. Any issues with third-party integrations should be directed to the respective provider.`, + content: + "AI outputs may be inaccurate, incomplete or unsuitable for a particular purpose. Review outputs and workflow behavior before relying on them or allowing them to affect external systems or other people. You are responsible for the instructions, permissions and actions you authorize through your workflows. Do not use AI output as the sole basis for a decision producing legal or similarly significant effects on an individual without the safeguards required by applicable law. This paragraph does not transfer Sim's own legal obligations to you or exclude mandatory consumer remedies.", }, ], }, @@ -161,16 +274,16 @@ export const TERMS_CONFIG: LegalPageConfig = { id: 'acceptable-use', heading: '9. Acceptable Use', blocks: [ - { kind: 'paragraph', content: `You agree not to use the Service:` }, + { kind: 'paragraph', content: 'You agree not to use the Service:' }, { kind: 'list', items: [ - `In any way that violates any applicable national or international law or regulation.`, - `For the purpose of exploiting, harming, or attempting to exploit or harm minors in any way.`, - `To transmit, or procure the sending of, any advertising or promotional material, including any "junk mail", "chain letter," "spam," or any other similar solicitation.`, - `To impersonate or attempt to impersonate Sim, Inc, a Sim employee, another user, or any other person or entity.`, - `In any way that infringes upon the rights of others, or in any way is illegal, threatening, fraudulent, or harmful.`, - `To engage in any other conduct that restricts or inhibits anyone's use or enjoyment of the Service, or which, as determined by us, may harm Sim, Inc or users of the Service or expose them to liability.`, + 'In any way that violates any applicable national or international law or regulation.', + 'For the purpose of exploiting, harming, or attempting to exploit or harm minors in any way.', + 'To transmit, or procure the sending of, unsolicited or unlawful advertising or promotional material, including spam or chain letters.', + 'To impersonate or attempt to impersonate Sim Studio, Inc., a Sim employee, another user, or any other person or entity.', + 'In any way that infringes upon the rights of others, or in any way is illegal, threatening, fraudulent, or harmful.', + "To engage in any other conduct that restricts or inhibits anyone's use or enjoyment of the Service, or which, as determined by us, may harm Sim Studio, Inc. or users of the Service or expose them to liability.", ], }, ], @@ -181,11 +294,32 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `We may terminate or suspend your account immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms.`, + content: ( + <> + { + 'Closure by you. You may request account closure through an available account-closure control or by contacting ' + } + legal@sim.ai + { + '. An account-closure request is also a request to cancel subscription renewal, and no renewal charge will be made after closure takes effect. Fees and usage charges properly incurred before closure remain payable, subject to applicable law. Simply stopping use does not close your account or cancel renewal. If you only want to stop renewal while retaining access until the end of the billing period, follow Section 4.' + } + + ), + }, + { + kind: 'paragraph', + content: + 'Suspension or termination for cause. We may suspend or terminate access for a material breach of these Terms, failure to pay amounts properly due, unlawful use, or a reasonably identified fraud, security or other serious risk to the Service or others, or where required by law. Where lawful and reasonably practicable, we will notify you of the reason and provide an opportunity to resolve a remediable issue. We may act immediately where notice or delay would create a risk, frustrate an investigation, or violate law.', }, { kind: 'paragraph', - content: `Upon termination, your right to use the Service will immediately cease. If you wish to terminate your account, you may simply discontinue using the Service.`, + content: + "Termination without your breach. If we end your access for our convenience or discontinue the paid Service you purchased without a breach by you, we will give at least 30 days' prior notice, unless earlier action is required by law or reasonably necessary to address an urgent security risk. Whenever we terminate your access without a breach by you, including termination for legal or risk-based reasons under the preceding paragraph, we will refund the unused portion of prepaid subscription fees for the period after termination takes effect, unless payment is prohibited by law. The right to act immediately in those circumstances does not remove this refund commitment. Charges for usage already incurred are not refunded unless required by law. Any additional mandatory refund or other remedy remains available.", + }, + { + kind: 'paragraph', + content: + 'Effect of closure or termination. Ordinary access to the Service ends when closure or termination takes effect, subject to the export-request process in Section 5. Retention and deletion follow Section 5; termination does not extinguish your statutory data protection rights. Accrued payment obligations, applicable ownership rights and the provisions on liability and dispute resolution survive only to the extent their nature requires and applicable law permits. No survival provision authorizes retaining data longer than otherwise permitted.', }, ], }, @@ -195,16 +329,18 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `In no event shall Sim, Inc, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from:`, + content: + 'Nothing in these Terms excludes or limits liability that cannot lawfully be excluded or limited. This includes liability for fraud or fraudulent misrepresentation, willful misconduct, death or personal injury caused by negligence where protected by applicable law, and any other non-excludable liability or mandatory consumer or data protection remedy.', }, { - kind: 'list', - items: [ - `Your access to or use of or inability to access or use the Service;`, - `Any conduct or content of any third party on the Service;`, - `Any content obtained from the Service; and`, - `Unauthorized access, use or alteration of your transmissions or content, whether based on warranty, contract, tort (including negligence) or any other legal theory, whether or not we have been informed of the possibility of such damage.`, - ], + kind: 'paragraph', + content: + 'Subject to the paragraph above, to the maximum extent permitted by applicable law, Sim Studio, Inc. and its directors, employees, agents, suppliers and affiliates will not be liable for indirect, incidental, special, consequential or punitive damages arising out of or relating to the Service, including loss of profits, business opportunities, goodwill or data to the extent those losses fall within an excluded category under applicable law. This applies whether the claim is based on contract, tort or another legal theory and whether the possibility of the loss was disclosed to us.', + }, + { + kind: 'paragraph', + content: + 'This section does not remove a refund expressly promised in these Terms or a separate written agreement, excuse Sim from its own binding data protection obligations, or exclude compensation or other remedies that applicable law requires to remain available.', }, ], }, @@ -214,20 +350,13 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `Your use of the Service is at your sole risk. The Service is provided on an "AS IS" and "AS AVAILABLE" basis. The Service is provided without warranties of any kind, whether express or implied, including, but not limited to, implied warranties of merchantability, fitness for a particular purpose, non-infringement or course of performance.`, + content: + 'Except for express commitments in these Terms or a separate written agreement, and to the extent permitted by applicable law, the Service is provided on an "AS IS" and "AS AVAILABLE" basis. To that extent, we disclaim implied warranties of merchantability, fitness for a particular purpose, non-infringement and warranties arising from a course of performance.', }, { kind: 'paragraph', - content: `Sim, Inc, its subsidiaries, affiliates, and its licensors do not warrant that:`, - }, - { - kind: 'list', - items: [ - `The Service will function uninterrupted, secure or available at any particular time or location;`, - `Any errors or defects will be corrected;`, - `The Service is free of viruses or other harmful components; or`, - `The results of using the Service will meet your requirements.`, - ], + content: + 'We do not guarantee that the Service will be uninterrupted or error-free, that every defect will be corrected, or that AI outputs or other results will meet your requirements. These statements do not override an express service commitment or exclude statutory requirements concerning reasonable care and skill, conformity, security or other rights that cannot lawfully be excluded.', }, ], }, @@ -237,7 +366,18 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `You agree to indemnify, defend, and hold harmless Sim, Inc and its officers, directors, employees, and agents from any claims, damages, losses, liabilities, and expenses (including reasonable attorneys' fees) arising from your use of the Service, your violation of these Terms, or your violation of any rights of another party.`, + content: + "If you use the Service for business purposes, to the extent permitted by law you agree to defend and indemnify Sim Studio, Inc. and its officers, directors, employees and agents against third-party claims and resulting damages, liabilities and reasonable legal costs arising from your material breach of these Terms, your unlawful use of the Service, or an allegation that Your Data infringes a third party's rights. This does not apply to the extent a claim results from Sim's breach of these Terms, negligence, fraud or willful misconduct.", + }, + { + kind: 'paragraph', + content: + "We will promptly notify you of a claim, allow you to control its defense with competent counsel, and provide reasonable cooperation at your expense. Delay in notice reduces your obligations only to the extent it materially prejudices your defense. You may not settle a claim in a way that admits fault by, imposes a non-monetary obligation on, or fails to release an indemnified party without that party's prior written consent, which will not be unreasonably withheld.", + }, + { + kind: 'paragraph', + content: + 'This contractual indemnity does not apply to individuals using the Service primarily for personal, family or household purposes. It does not require anyone to pay fees or costs that a court or arbitrator cannot lawfully award, or override the consumer protections in Section 15.', }, ], }, @@ -247,11 +387,23 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `These Terms shall be governed and construed in accordance with the laws of the United States, without regard to its conflict of law provisions.`, + content: + 'These Terms are governed by the laws of the State of California and applicable United States federal law, without applying conflict-of-law rules that would displace that choice, except where mandatory law requires otherwise. The Federal Arbitration Act governs Section 15 to the extent it applies.', + }, + { + kind: 'paragraph', + content: + "Subject to Section 15, disputes that may be heard in court will be brought in the state or federal courts located in San Francisco, California, and the parties consent to those courts' jurisdiction. This does not restrict a qualifying small-claims action in another competent small-claims court or a consumer's right to bring proceedings in a court available under mandatory law.", + }, + { + kind: 'paragraph', + content: + 'If you are a consumer, this choice of law and courts does not deprive you of protections or remedies that cannot be excluded under the law applicable to you, including mandatory protections in your country of habitual residence. Nothing in these Terms prevents complaints to regulators, the exercise of statutory privacy rights, or use of an applicable Data Privacy Framework complaint or recourse mechanism.', }, { kind: 'paragraph', - content: `Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions of these Terms will remain in effect.`, + content: + 'Our failure to enforce a provision is not a waiver of it. If a provision is unenforceable, it will be limited or severed to the extent permitted by law, and the remaining provisions will continue in effect, subject to the specific treatment of arbitration and class-waiver provisions in Section 15. A separate written agreement between you and Sim controls to the extent it expressly overrides these Terms for the services it covers.', }, ], }, @@ -259,33 +411,68 @@ export const TERMS_CONFIG: LegalPageConfig = { id: 'arbitration', heading: '15. Arbitration Agreement', blocks: [ + { + kind: 'callout', + content: + 'PLEASE READ THIS SECTION CAREFULLY. WHERE ENFORCEABLE AND UNLESS YOU OPT OUT, IT REQUIRES BOTH YOU AND SIM TO RESOLVE COVERED DISPUTES THROUGH BINDING ARBITRATION RATHER THAN A COURT OR JURY TRIAL, AND LIMITS CLASS PROCEEDINGS. IT DOES NOT WAIVE RIGHTS OR REMEDIES THAT CANNOT LAWFULLY BE WAIVED.', + }, { kind: 'paragraph', - content: `Please read the following arbitration agreement carefully. It requires you to arbitrate disputes with Sim, Inc, its parent companies, subsidiaries, affiliates, successors and assigns and all of their respective officers, directors, employees, agents, and representatives (collectively, the "Company Parties") and limits the manner in which you can seek relief from the Company Parties.`, + content: + 'Scope and exceptions. You and Sim Studio, Inc. agree to arbitrate disputes arising out of or relating to the Service or these Terms. Either party may instead bring an individual claim in a competent small-claims court or seek court relief for infringement or misuse of intellectual property rights. Claims that applicable law does not permit to be subjected to pre-dispute arbitration are excluded to that extent. This section does not prevent regulatory complaints or participation in applicable Data Privacy Framework complaint, independent recourse or arbitration procedures.', }, { kind: 'paragraph', - content: `You agree that any dispute between you and any of the Company Parties relating to the Site, the Service or these Terms will be resolved by binding arbitration, rather than in court, except that (1) you and the Company Parties may assert individualized claims in small claims court if the claims qualify, remain in such court and advance solely on an individual, non-class basis; and (2) you or the Company Parties may seek equitable relief in court for infringement or other misuse of intellectual property rights.`, + content: ( + <> + { + 'Rules and procedure. The Federal Arbitration Act applies to the extent applicable. Arbitration will be administered by JAMS before one neutral arbitrator under the ' + } + + JAMS Comprehensive Arbitration Rules and Procedures + + {', as modified by this section. If you are a consumer as defined by JAMS, the '} + + JAMS Consumer Minimum Standards + + { + ' also apply and control any conflict that would reduce their protections. A consumer is generally an individual acquiring the Service primarily for personal, family or household purposes. Applicable mandatory law controls any remaining conflict.' + } + + ), }, { kind: 'paragraph', - content: `The Federal Arbitration Act governs the interpretation and enforcement of this Arbitration Agreement. The arbitration will be conducted by JAMS, an established alternative dispute resolution provider.`, + content: + 'Each party may have legal representation and a reasonable opportunity to participate in selecting a neutral arbitrator and exchange relevant, non-privileged information. The location or method of a consumer hearing must not prevent the consumer from accessing arbitration; JAMS will determine an appropriate accessible arrangement under its standards and applicable law. The arbitrator may award all relief available under applicable law for an arbitrable claim and will issue a reasoned written award. A court, not the arbitrator, will decide disputes about formation, enforceability or the scope of this arbitration agreement, including the class-action waiver, notwithstanding any contrary delegation in the incorporated rules.', }, { - kind: 'callout', - content: `YOU AND COMPANY AGREE THAT EACH OF US MAY BRING CLAIMS AGAINST THE OTHER ONLY ON AN INDIVIDUAL BASIS AND NOT ON A CLASS, REPRESENTATIVE, OR COLLECTIVE BASIS. ONLY INDIVIDUAL RELIEF IS AVAILABLE, AND DISPUTES OF MORE THAN ONE CUSTOMER OR USER CANNOT BE ARBITRATED OR CONSOLIDATED WITH THOSE OF ANY OTHER CUSTOMER OR USER.`, + kind: 'paragraph', + content: + "Consumer fees. If a consumer initiates arbitration, the consumer will pay no more than $250 in JAMS filing or administrative fees, or a lower amount required by applicable law or JAMS policy. Sim will pay the remaining JAMS and arbitrator fees. If Sim initiates arbitration against a consumer, Sim will pay all JAMS and arbitrator fees. Each party bears its own lawyer's fees unless applicable law permits an award; a consumer will not be required to pay Sim's fees merely because the consumer does not prevail. For non-consumer disputes, costs are governed by the applicable JAMS rules and law.", + }, + { + kind: 'paragraph', + content: + "Individual proceedings and non-waivable relief. To the extent permitted by law, covered claims must be brought in an individual capacity, not as a plaintiff or class member in a class, collective or representative proceeding. This does not waive a right to public injunctive relief or any other remedy that cannot lawfully be waived. If a particular claim or request for relief cannot lawfully be arbitrated on an individual basis, it may proceed in a competent court to the extent required by law; other arbitrable claims remain subject to this section. Class arbitration requires both parties' express written agreement after the dispute arises.", }, { kind: 'paragraph', content: ( <> - You have the right to opt out of the provisions of this Arbitration Agreement by - sending a timely written notice of your decision to opt out to:{' '} - legal@sim.ai within 30 days after - first becoming subject to this Arbitration Agreement. + {'Opt-out. You may opt out of this revised arbitration agreement by emailing '} + legal@sim.ai + { + ' with your name, the email address associated with your account if any, and a clear statement that you opt out. Send the notice within 30 days after you accept this version of the Terms or, for an existing account, within 30 days after we notify you of this revised arbitration agreement, whichever is later. A valid earlier opt-out remains effective. Opting out does not affect the rest of these Terms.' + } ), }, + { + kind: 'paragraph', + content: + 'Existing disputes and provider availability. These revisions do not apply retroactively to a dispute that arose before the revised agreement became effective for you; any applicable prior agreement and mandatory law govern that dispute. If JAMS is unavailable to administer a covered dispute and the parties cannot agree on another provider, the dispute may proceed in a competent court, subject to applicable law and any court order. No provision limits a remedy available because a party fails to pay fees or comply with its arbitration obligations.', + }, ], }, { @@ -294,11 +481,18 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days' notice prior to any new terms taking effect. What constitutes a material change will be determined at our sole discretion.`, + content: + "We may update these Terms and will identify the updated version and its effective date. For changes that materially adversely affect existing users' rights or obligations, we will provide at least 30 days' advance notice by email or a prominent notice through the Service, unless a shorter period is reasonably necessary to comply with law or address an urgent security risk. In that case, we will explain the reason and give as much notice as reasonably possible. Price changes remain subject to Section 3, and arbitration changes and opt-out rights are addressed in Section 15.", + }, + { + kind: 'paragraph', + content: + 'Changes apply prospectively. Continued use after the notified effective date constitutes acceptance only where permitted by applicable law. Where affirmative acceptance is required, we will obtain it before applying the change. If you do not agree to a change, you may cancel renewal under Section 4 or request account closure under Section 10; simply stopping use does not cancel billing.', }, { kind: 'paragraph', - content: `By continuing to access or use our Service after those revisions become effective, you agree to be bound by the revised terms. If you do not agree to the new terms, please stop using the Service.`, + content: + 'The minimum-age requirement in Section 1 applies to new accounts created on or after the date that requirement is first published. For accounts created before that date, the requirement applies 30 days after we notify the account holder of it, unless applicable law requires it to apply sooner. This transition does not permit any use that is otherwise unlawful.', }, ], }, @@ -308,24 +502,25 @@ export const TERMS_CONFIG: LegalPageConfig = { blocks: [ { kind: 'paragraph', - content: `We respect the intellectual property of others and ask that users of our Service do the same. If you believe that one of our users is, through the use of our Service, unlawfully infringing the copyright(s) in a work, please send a notice to our designated Copyright Agent, including the following information:`, + content: + 'We respect the intellectual property of others and ask that users of our Service do the same. If you believe that one of our users is, through the use of our Service, unlawfully infringing the copyright(s) in a work, please send a notice to our designated Copyright Agent, including the following information:', }, { kind: 'list', items: [ - `Your physical or electronic signature;`, - `Identification of the copyrighted work(s) that you claim to have been infringed;`, - `Identification of the material on our services that you claim is infringing;`, - `Your address, telephone number, and e-mail address;`, - `A statement that you have a good-faith belief that the disputed use is not authorized by the copyright owner, its agent, or the law; and`, - `A statement, made under the penalty of perjury, that the above information in your notice is accurate and that you are the copyright owner or authorized to act on the copyright owner's behalf.`, + 'Your physical or electronic signature;', + 'Identification of the copyrighted work(s) that you claim to have been infringed;', + 'Identification of the material on our services that you claim is infringing;', + 'Your address, telephone number, and e-mail address;', + 'A statement that you have a good-faith belief that the disputed use is not authorized by the copyright owner, its agent, or the law; and', + "A statement, made under the penalty of perjury, that the above information in your notice is accurate and that you are the copyright owner or authorized to act on the copyright owner's behalf.", ], }, { kind: 'paragraph', content: ( <> - Our Copyright Agent can be reached at:{' '} + {'Our Copyright Agent can be reached at: '} copyright@sim.ai ), @@ -340,7 +535,7 @@ export const TERMS_CONFIG: LegalPageConfig = { kind: 'paragraph', content: ( <> - If you have any questions about these Terms, please contact us at:{' '} + {'If you have any questions about these Terms, please contact us at: '} legal@sim.ai ), From 7050250df443bcb77a93cb90d7084d06025c8bc1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 15:58:29 -0700 Subject: [PATCH 14/68] fix(projects): restore workspace deletion and tighten Project lifecycle (#8631) * fix(projects): restore workspace deletion and tighten Project lifecycle - Archive a Project with its last active environment instead of refusing the workspace delete; account deletion follows the same rule, and the implicit archive is audited - Gate Project APIs on a `projects` AppConfig flag (PROJECT_API_ENABLED fallback) - Run Project reads in a read-only snapshot without locks; list Projects from the caller's grants with batched authorization - Batch workflow archival, Project transfer and owner reassignment; move Project ownership on organization ownership transfer - Reuse shared advisory-lock and text-array helpers; narrow admin-move conflict mapping to ProjectConflictError * improvement(projects): unify environment archive and align with shared patterns - Archive a workspace's workflows atomically with it through one archiveEnvironmentInTransaction shared by workspace delete and Project archive; the workspace row is locked before the sweep so concurrent creates are covered - Scope the Project lock timeout to lock acquisition and map lock timeouts and deadlocks to a retryable conflict; backfill and multi-Project locks use the shared advisory-lock helpers in code-unit order - Shared orchestrationFailureResponse for raw routes; contracts use the ID primitives and export only what is consumed; audit enums and mock in sync - Project restrictions section matches its sibling settings rows - Batch account-deletion Project loads/locks; skip inconsistent Projects in lists - Harden the foundation integration suite (user-keyed cleanup, poll helper, pid-scoped waits, precise assertions) * fix(projects): trim the requested organization id before validating it * fix(projects): address review on Project locking, archive notifications and list policy cost * fix(projects): keep archive retries from re-stamping MCP servers and isolate post-commit notifications --- apps/sim/app/api/files/uploads/utils.ts | 10 +- .../api/superuser/import-workflow/route.ts | 30 +- apps/sim/app/api/table/utils.ts | 10 +- .../api/v1/admin/workflows/import/route.ts | 28 +- .../v1/admin/workspaces/[id]/import/route.ts | 24 +- apps/sim/app/api/workspaces/[id]/route.ts | 24 +- .../components/group-detail.tsx | 20 +- .../components/project-issue-restrictions.tsx | 86 ++-- .../application/lineage-details.ts | 20 +- .../lib/copy/copy-workflows.ts | 2 - .../ee/workspace-forking/lib/create-fork.ts | 24 +- .../workspace-forking/lib/lineage/unlink.ts | 3 +- .../lib/promote/promote.test.ts | 5 +- .../workspace-forking/lib/promote/promote.ts | 3 + apps/sim/hooks/queries/projects.ts | 6 +- apps/sim/lib/api/contracts/projects.ts | 51 +-- .../lib/api/server/orchestration-response.ts | 28 ++ .../lib/billing/organizations/membership.ts | 34 +- apps/sim/lib/core/config/env.ts | 2 +- apps/sim/lib/core/config/feature-flags.ts | 7 + apps/sim/lib/db/advisory-locks.ts | 19 + apps/sim/lib/db/arrays.ts | 15 + .../workspace-lifecycle.integration.ts | 3 - apps/sim/lib/knowledge/access/predicate.ts | 15 +- .../connectors/member-observations.ts | 2 +- .../knowledge/connectors/sync-persistence.ts | 2 +- apps/sim/lib/knowledge/search/vector-leg.ts | 2 +- apps/sim/lib/knowledge/service.ts | 3 +- apps/sim/lib/projects/README.md | 12 +- .../__integration__/foundation.integration.ts | 382 ++++++++--------- apps/sim/lib/projects/account-deletion.ts | 221 +++++++--- .../lib/projects/application/authorization.ts | 249 ++++++++--- .../projects/application/create-project.ts | 24 +- .../sim/lib/projects/application/use-cases.ts | 151 ++++--- apps/sim/lib/projects/create-input.ts | 3 +- apps/sim/lib/projects/lifecycle.ts | 70 +--- apps/sim/lib/projects/membership.ts | 388 +++++++++++------- apps/sim/lib/projects/rollout.server.ts | 6 +- apps/sim/lib/users/account-deletion.ts | 19 +- apps/sim/lib/webhooks/path-claims.ts | 12 +- apps/sim/lib/workflows/lifecycle.ts | 75 ++-- .../persistence/deployment-operations.ts | 7 +- .../lib/workflows/persistence/duplicate.ts | 26 +- .../workflows/persistence/new-workflow-row.ts | 34 +- apps/sim/lib/workflows/persistence/utils.ts | 4 +- apps/sim/lib/workspaces/active-workspace.ts | 11 +- apps/sim/lib/workspaces/admin-move.ts | 5 +- apps/sim/lib/workspaces/create.ts | 45 +- apps/sim/lib/workspaces/lifecycle.ts | 165 ++++++-- knip.jsonc | 2 - packages/audit/src/types.ts | 9 +- packages/testing/src/mocks/audit.mock.ts | 5 + 52 files changed, 1414 insertions(+), 989 deletions(-) create mode 100644 apps/sim/lib/api/server/orchestration-response.ts create mode 100644 apps/sim/lib/db/arrays.ts diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts index e19ea1a9210..17e29390761 100644 --- a/apps/sim/app/api/files/uploads/utils.ts +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -4,8 +4,8 @@ import { type InternalFileUploadSession, internalFileUploadSessionSchema, } from '@/lib/api/contracts/upload-sessions' +import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response' import { getSession } from '@/lib/auth' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers' @@ -32,13 +32,7 @@ export async function requireUploadUser(): Promise { null ) - await db.transaction(async (tx) => { - await tx.insert(workflow).values( - await buildNewWorkflowRow(tx, { - id: newWorkflowId, - userId: session.user.id, - workspaceId: targetWorkspaceId, - folderId: null, - name: dedupedName, - description: sourceWorkflow.description, - variables: sourceWorkflow.variables || {}, - }) - ) - }) + await db.transaction((tx) => + insertNewWorkflowRow(tx, { + id: newWorkflowId, + userId: session.user.id, + workspaceId: targetWorkspaceId, + folderId: null, + name: dedupedName, + description: sourceWorkflow.description, + variables: sourceWorkflow.variables || {}, + }) + ) // Save using existing persistence logic const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData, { @@ -228,7 +226,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { copilotChatsImported, }) } catch (error) { - if (error instanceof OrchestrationError && error.code === 'not_found') { + if (asOrchestrationError(error)?.code === 'not_found') { return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 }) } logger.error('Error importing workflow', error) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 875b8005d0c..62d27b5e86e 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -2,8 +2,8 @@ import { createLogger } from '@sim/logger' import { permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' +import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response' import { - asOrchestrationError, messageForOrchestrationError, type OrchestrationErrorCode, statusForOrchestrationError, @@ -137,13 +137,7 @@ export function orchestrationErrorResponse(error: unknown): NextResponse | null const lockResponse = tableLockErrorResponse(error) if (lockResponse) return lockResponse - const classified = asOrchestrationError(error) - if (!classified) return null - - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) + return orchestrationFailureResponse(error) } /** diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 12a02a6c855..47c31583a1c 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -28,10 +28,10 @@ import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' +import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' @@ -116,18 +116,16 @@ export const POST = withRouteHandler( const workflowId = generateId() const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null) - await db.transaction(async (tx) => { - await tx.insert(workflow).values( - await buildNewWorkflowRow(tx, { - id: workflowId, - userId: workspaceData.ownerId, - workspaceId, - folderId: folderId || null, - name: dedupedName, - description: workflowDescription, - }) - ) - }) + await db.transaction((tx) => + insertNewWorkflowRow(tx, { + id: workflowId, + userId: workspaceData.ownerId, + workspaceId, + folderId: folderId || null, + name: dedupedName, + description: workflowDescription, + }) + ) /** * Same normalization the editor and the v1 import API run, via the one @@ -183,7 +181,7 @@ export const POST = withRouteHandler( return NextResponse.json(response) } catch (error) { - if (error instanceof OrchestrationError && error.code === 'not_found') { + if (asOrchestrationError(error)?.code === 'not_found') { return notFoundResponse('Workspace') } if (error instanceof FolderNotFoundError) { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 44ee2523b7b..34964a8e287 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -45,7 +45,7 @@ import { extractWorkflowsFromZip, parseWorkflowJson, } from '@/lib/workflows/operations/import-export' -import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' +import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' @@ -350,18 +350,16 @@ async function importSingleWorkflow( const workflowId = generateId() const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId) - await db.transaction(async (tx) => { - await tx.insert(workflow).values( - await buildNewWorkflowRow(tx, { - id: workflowId, - userId: ownerId, - workspaceId, - folderId: targetFolderId, - name: dedupedName, - description: workflowData.metadata?.description || 'Imported via Admin API', - }) - ) - }) + await db.transaction((tx) => + insertNewWorkflowRow(tx, { + id: workflowId, + userId: ownerId, + workspaceId, + folderId: targetFolderId, + name: dedupedName, + description: workflowData.metadata?.description || 'Imported via Admin API', + }) + ) /** * Same normalization the editor, the v1 import API and the single-workflow diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index afc662ad4b7..1ddd4e05cc2 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -5,9 +5,9 @@ import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/contracts' import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response' import { getSession } from '@/lib/auth' import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' -import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { archiveWorkspace } from '@/lib/workspaces/lifecycle' @@ -307,6 +307,20 @@ export const DELETE = withRouteHandler( }, request, }) + if (archiveResult.archivedProject) { + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.PROJECT_ARCHIVED, + resourceType: AuditResourceType.PROJECT, + resourceId: archiveResult.archivedProject.id, + resourceName: archiveResult.archivedProject.name, + description: `Archived Project "${archiveResult.archivedProject.name}" with its last active environment`, + request, + }) + } captureServerEvent( session.user.id, @@ -317,12 +331,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof OrchestrationError) { - return NextResponse.json( - { error: error.message }, - { status: statusForOrchestrationError(error.code) } - ) - } + const failure = orchestrationFailureResponse(error, 'Failed to delete workspace') + if (failure) return failure logger.error(`Error deleting workspace ${workspaceId}:`, error) return NextResponse.json({ error: 'Failed to delete workspace' }, { status: 500 }) } diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 166c83e3284..ee7190065d5 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1784,16 +1784,6 @@ export function GroupDetail({ {configTab === 'platform' && (
- - setEditingConfig((previous) => ({ - ...previous, - deniedPartialAccessProjectIssues: value, - })) - } - />
))} + + setEditingConfig((previous) => ({ + ...previous, + deniedPartialAccessProjectIssues: value, + })) + } + />
)} diff --git a/apps/sim/ee/access-control/components/project-issue-restrictions.tsx b/apps/sim/ee/access-control/components/project-issue-restrictions.tsx index d2404c7b548..689ab44738c 100644 --- a/apps/sim/ee/access-control/components/project-issue-restrictions.tsx +++ b/apps/sim/ee/access-control/components/project-issue-restrictions.tsx @@ -1,7 +1,8 @@ 'use client' -import { Checkbox, Chip } from '@sim/emcn' +import { Checkbox, Chip, Info, OverflowText } from '@sim/emcn' import { isApiClientError } from '@/lib/api/client/errors' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useProjects } from '@/hooks/queries/projects' interface ProjectIssueRestrictionsProps { @@ -10,7 +11,10 @@ interface ProjectIssueRestrictionsProps { onChange: (value: string[]) => void } -/** Project choices use the authorized inventory; policy remains enforced at the application boundary. */ +/** + * Project choices use the authorized inventory; policy remains enforced at the + * application boundary. + */ export function ProjectIssueRestrictions({ organizationId, value, @@ -20,44 +24,58 @@ export function ProjectIssueRestrictions({ if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) { return null } + const choices = projects.data?.pages.flatMap((page) => page.projects) ?? [] + if (!projects.error && choices.length === 0) return null const selected = new Set(value) return ( -
-

Restrict Issues for partial-access teammates

-

- For selected Projects, teammates governed by this group need access to every active - environment to use Issues. -

+ + For selected Projects, teammates governed by this group need access to every active + environment to use Issues. + + } + action={ + projects.hasNextPage ? ( + void projects.fetchNextPage()} + > + {projects.isFetchingNextPage ? 'Loading…' : 'Load more'} + + ) : undefined + } + > {projects.error && ( -

{projects.error.message}

+

{projects.error.message}

)} - {projects.data?.pages - .flatMap((page) => page.projects) - .map((project) => ( -
+
+ ) } diff --git a/apps/sim/ee/workspace-forking/application/lineage-details.ts b/apps/sim/ee/workspace-forking/application/lineage-details.ts index 0b640febdc0..951bf9061b9 100644 --- a/apps/sim/ee/workspace-forking/application/lineage-details.ts +++ b/apps/sim/ee/workspace-forking/application/lineage-details.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { workspace } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import { readForkSyncNewWorkflowsExcluded } from '@/lib/workflows/persistence/new-workflow-row' +import { and, eq, isNull } from 'drizzle-orm' import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store' @@ -24,6 +23,21 @@ async function withViewerAccess { + const [row] = await db + .select({ excluded: workspace.forkSyncNewWorkflowsExcluded }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + return row?.excluded ?? false +} + export const getWorkspaceForkLineageDetails = defineForkUseCase({ operation: forkOperations.discover, availability: true, @@ -40,7 +54,7 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({ getForkChildren(workspaceId), getUndoableRunForTarget(db, workspaceId), // Lineage-uniform, so this workspace's own value is the lineage's value. - readForkSyncNewWorkflowsExcluded(db, workspaceId), + readForkSyncNewWorkflowsExcluded(workspaceId), ]) const [parent, children] = await Promise.all([ diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 21c9c21b83d..847f2ec2984 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -26,7 +26,6 @@ import { type SubBlockTransform, } from '@/lib/workflows/references/remap-references' import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' -import { lockActiveWorkspace } from '@/lib/workspaces/active-workspace' import { deriveForkBlockId, type ForkBlockIdResolver, @@ -503,7 +502,6 @@ export async function copyWorkflowStateIntoTarget( requestId = 'unknown', } = params - await lockActiveWorkspace(tx, targetWorkspaceId) const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null const varIdMapping = new Map() diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 995652275e4..69c161ed921 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { permissions, projectWorkspace, workflow, workspace } from '@sim/db/schema' +import { permissions, projectWorkspace, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import type { PermissionType } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' @@ -9,7 +9,7 @@ import type { Workspace } from '@/lib/api/contracts/workspaces' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { requireForkProject } from '@/lib/projects/membership' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' +import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { collectReferencedDocumentIds, @@ -511,17 +511,15 @@ export async function createFork(params: CreateForkParams): Promise { resetDbChainMock() + queueTableRows(workspace, [{ name: 'Target' }]) + queueTableRows(workspace, [{ archivedAt: null, forkSyncNewWorkflowsExcluded: false }]) mockGetUsersWithPermissions.mockResolvedValue([]) mockLoadSourceDeployedStates.mockResolvedValue({ deployedWorkflows: [], diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.ts index 266a3bca4ab..60df6e03a4e 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.ts @@ -29,6 +29,7 @@ import { type ForkRemapKind, } from '@/lib/workflows/references/remap-references' import { getMcpServerMetaByIds } from '@/lib/workflows/references/resources' +import { lockActiveWorkspace } from '@/lib/workspaces/active-workspace' import { findWorkspaceOperationReceipt, lockWorkspaceOperationRequest, @@ -629,6 +630,8 @@ export async function promoteFork(params: PromoteForkParams): Promise page.nextCursor, staleTime: PROJECT_LIST_STALE_TIME, retry: (failureCount, error) => - !(isApiClientError(error) && error.status === 503) && failureCount < 3, + failureCount < 1 && + (!isApiClientError(error) || + error.status === 408 || + error.status === 429 || + (error.status >= 500 && error.status !== 503)), enabled: Boolean(organizationId), }) } diff --git a/apps/sim/lib/api/contracts/projects.ts b/apps/sim/lib/api/contracts/projects.ts index f25549855e0..0339cd07738 100644 --- a/apps/sim/lib/api/contracts/projects.ts +++ b/apps/sim/lib/api/contracts/projects.ts @@ -1,15 +1,18 @@ import { z } from 'zod' -import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + nonEmptyIdSchema, + organizationIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { createProjectInputSchema } from '@/lib/projects/create-input' -export const projectEnvironmentSchema = z.object({ +const projectEnvironmentSchema = z.object({ id: nonEmptyIdSchema, name: z.string(), forkedFromWorkspaceId: nonEmptyIdSchema.nullable(), }) -export type ProjectEnvironment = z.output -export const projectSchema = z.object({ +const projectSchema = z.object({ id: nonEmptyIdSchema, name: z.string(), organizationId: nonEmptyIdSchema.nullable(), @@ -20,33 +23,27 @@ export const projectSchema = z.object({ environments: z.array(projectEnvironmentSchema), capabilities: z.object({ administer: z.boolean(), issues: z.boolean() }), }) -export type Project = z.output -export const projectParamsSchema = z.object({ id: nonEmptyIdSchema }) -export type ProjectParams = z.input -export const projectQuerySchema = z.object({ - organizationId: nonEmptyIdSchema.optional(), - workspaceId: nonEmptyIdSchema.optional(), +const projectParamsSchema = z.object({ id: nonEmptyIdSchema }) +const projectQuerySchema = z.object({ + organizationId: organizationIdSchema.optional(), + workspaceId: workspaceIdSchema.optional(), }) -export type ProjectQuery = z.input -export const listProjectsQuerySchema = z.object({ - organizationId: nonEmptyIdSchema.optional(), +const listProjectsQuerySchema = z.object({ + organizationId: organizationIdSchema.optional(), cursor: nonEmptyIdSchema.optional(), limit: z.coerce.number().int().min(1).max(100).default(50), }) -export type ListProjectsQuery = z.input -export const listProjectsResponseSchema = z.object({ +const listProjectsResponseSchema = z.object({ projects: z.array(projectSchema), nextCursor: nonEmptyIdSchema.nullable(), }) -export type ListProjectsResponse = z.output export const listProjectsContract = defineRouteContract({ method: 'GET', path: '/api/projects', query: listProjectsQuerySchema, response: { mode: 'json', schema: listProjectsResponseSchema }, }) -export const getProjectResponseSchema = z.object({ project: projectSchema }) -export type GetProjectResponse = z.output +const getProjectResponseSchema = z.object({ project: projectSchema }) export const getProjectContract = defineRouteContract({ method: 'GET', path: '/api/projects/[id]', @@ -54,10 +51,8 @@ export const getProjectContract = defineRouteContract({ query: projectQuerySchema, response: { mode: 'json', schema: getProjectResponseSchema }, }) -export const renameProjectBodySchema = z.object({ name: z.string().trim().min(1).max(100) }) -export type RenameProjectBody = z.input -export const renameProjectResponseSchema = z.object({ id: nonEmptyIdSchema, name: z.string() }) -export type RenameProjectResponse = z.output +const renameProjectBodySchema = z.object({ name: z.string().trim().min(1).max(100) }) +const renameProjectResponseSchema = z.object({ id: nonEmptyIdSchema, name: z.string() }) export const renameProjectContract = defineRouteContract({ method: 'PATCH', path: '/api/projects/[id]', @@ -65,11 +60,10 @@ export const renameProjectContract = defineRouteContract({ body: renameProjectBodySchema, response: { mode: 'json', schema: renameProjectResponseSchema }, }) -export const archiveProjectResponseSchema = z.object({ +const archiveProjectResponseSchema = z.object({ id: nonEmptyIdSchema, archived: z.boolean(), }) -export type ArchiveProjectResponse = z.output export const archiveProjectContract = defineRouteContract({ method: 'DELETE', path: '/api/projects/[id]', @@ -77,8 +71,7 @@ export const archiveProjectContract = defineRouteContract({ response: { mode: 'json', schema: archiveProjectResponseSchema }, }) -export const workspaceProjectParamsSchema = z.object({ workspaceId: nonEmptyIdSchema }) -export type WorkspaceProjectParams = z.input +const workspaceProjectParamsSchema = z.object({ workspaceId: workspaceIdSchema }) export const getWorkspaceProjectContract = defineRouteContract({ method: 'GET', path: '/api/projects/by-workspace/[workspaceId]', @@ -86,13 +79,11 @@ export const getWorkspaceProjectContract = defineRouteContract({ response: { mode: 'json', schema: getProjectResponseSchema }, }) -export const createProjectBodySchema = createProjectInputSchema -export type CreateProjectBody = z.input -export const createProjectResponseSchema = z.object({ +const createProjectBodySchema = createProjectInputSchema +const createProjectResponseSchema = z.object({ project: z.object({ id: nonEmptyIdSchema, name: z.string() }), initialEnvironment: z.object({ id: nonEmptyIdSchema, name: z.string() }), }) -export type CreateProjectResponse = z.output export const createProjectContract = defineRouteContract({ method: 'POST', path: '/api/projects', diff --git a/apps/sim/lib/api/server/orchestration-response.ts b/apps/sim/lib/api/server/orchestration-response.ts new file mode 100644 index 00000000000..684371a26fc --- /dev/null +++ b/apps/sim/lib/api/server/orchestration-response.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import { + asOrchestrationError, + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' + +/** + * Maps a classified domain failure anywhere in `error`'s cause chain to its status for a + * raw route; `null` when unclassified, so the caller logs it and returns its own 500. An + * `internal` code answers with `fallback` rather than a message that may carry internals. + */ +export function orchestrationFailureResponse( + error: unknown, + fallback = 'Internal server error' +): NextResponse | null { + const classified = asOrchestrationError(error) + if (!classified) return null + return NextResponse.json( + { + error: messageForOrchestrationError( + { error: classified.message, errorCode: classified.code }, + fallback + ), + }, + { status: statusForOrchestrationError(classified.code) } + ) +} diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 220b0dba82d..e7b25750441 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -15,7 +15,6 @@ import { organization, permissionGroupMember, permissions, - project, subscription as subscriptionTable, user, userStats, @@ -61,7 +60,7 @@ import { revokePersonalApiKeysTx, revokeUserSessionsTx, } from '@/lib/organizations/members/revocation' -import { lockProjectBackfillWrites, tryLockProject } from '@/lib/projects/membership' +import { reassignOrganizationProjects } from '@/lib/projects/membership' import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -557,25 +556,12 @@ async function reassignOwnedOrganizationResourcesTx({ const ownerId = ownerMembership?.userId if (!ownerId || ownerId === userId) return 0 - await lockProjectBackfillWrites(tx, workspaceIds) - const ownedProjects = await tx - .select({ id: project.id }) - .from(project) - .where(and(eq(project.organizationId, organizationId), eq(project.ownerId, userId))) - .orderBy(project.id) - for (const row of ownedProjects) { - await tryLockProject(tx, row.id) - await tx - .update(project) - .set({ ownerId, updatedAt: new Date() }) - .where( - and( - eq(project.id, row.id), - eq(project.ownerId, userId), - eq(project.organizationId, organizationId) - ) - ) - } + await reassignOrganizationProjects(tx, { + organizationId, + fromUserId: userId, + toUserId: ownerId, + workspaceIds, + }) /** Creator attribution must survive account deletion without changing document ACLs. */ await tx @@ -1850,6 +1836,12 @@ export async function transferOrganizationOwnership( .returning({ id: workspace.id }) result.workspacesReassigned = ownerUpdate.length + await reassignOrganizationProjects(tx, { + organizationId, + fromUserId: currentOwnerUserId, + toUserId: newOwnerUserId, + workspaceIds: ownerUpdate.map((workspaceRow) => workspaceRow.id), + }) const reassignedWorkspaceIds = Array.from( new Set([...billedWorkspaceIds, ...ownerUpdate.map((workspaceRow) => workspaceRow.id)]) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 9904b0143e3..4c889ae2a71 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -599,6 +599,7 @@ export const env = createEnv({ AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to) MSHIP_PLAN_MODE: z.boolean().optional(), DASHBOARDS: z.boolean().optional(), + PROJECT_API_ENABLED: z.boolean().optional(), // Fallback for the `projects` feature flag off AppConfig MSHIP_MODEL_SELECTOR: z.boolean().optional(), INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements) SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted (bypasses hosted requirements) @@ -669,7 +670,6 @@ export const env = createEnv({ // SSO Configuration (for script-based registration) SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality - PROJECT_API_ENABLED: z.boolean().optional(), // Expose Projects after backfill and contract enforcement SCIM_ENABLED: z.boolean().optional(), // Enable SCIM directory provisioning USAGE_MONITORING_ENABLED: z.boolean().optional(), // Enable organization usage monitoring on self-hosted (bypasses hosted requirements) SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 83925454c75..566d3481832 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -110,6 +110,13 @@ const FEATURE_FLAGS = { 'requires knowledge-member-access. Off-AppConfig falls back to CREDENTIAL_GROUPS.', fallback: 'CREDENTIAL_GROUPS', }, + projects: { + description: + 'Expose the Project APIs once the membership backfill has validated. Global on/off only; ' + + 'workspace creation assigns Projects and lifecycle protections apply either way. ' + + 'Off-AppConfig falls back to PROJECT_API_ENABLED.', + fallback: 'PROJECT_API_ENABLED', + }, 'knowledge-member-access': { description: 'Organization Search (live) and the permission-aware workspace connector modes: members ' + diff --git a/apps/sim/lib/db/advisory-locks.ts b/apps/sim/lib/db/advisory-locks.ts index dacbcac9c54..862e952b36e 100644 --- a/apps/sim/lib/db/advisory-locks.ts +++ b/apps/sim/lib/db/advisory-locks.ts @@ -1,4 +1,5 @@ import { type SQL, sql } from 'drizzle-orm' +import { textArrayLiteral } from '@/lib/db/arrays' import type { DbTransaction } from '@/lib/db/types' const LOCK_TAG_PATTERN = /^[a-z][a-z0-9_]*$/ @@ -45,6 +46,24 @@ export async function tryAcquireAdvisoryXactLock( return Boolean(lock?.acquired) } +/** + * Tries every transaction-scoped advisory lock in `keys` in one round trip, without + * waiting, so their order cannot deadlock. Returns whether all are held; locks taken + * alongside a refusal stay held until the transaction ends, so a caller that sees + * `false` should abort it. + */ +export async function tryAcquireAdvisoryXactLocks( + tx: DbTransaction, + tag: string, + keys: readonly string[] +): Promise { + if (keys.length === 0) return true + const [lock] = await tx.execute<{ acquired: boolean }>(sql` + SELECT bool_and(pg_try_advisory_xact_lock(hashtextextended(key, 0))) AS acquired + FROM unnest(${textArrayLiteral(keys)}) AS key ${lockTag(tag)}`) + return Boolean(lock?.acquired) +} + /** One lock of an {@link acquireAdvisoryXactLocks} set. */ export interface AdvisoryXactLockRequest { key: string diff --git a/apps/sim/lib/db/arrays.ts b/apps/sim/lib/db/arrays.ts new file mode 100644 index 00000000000..9bad5ea9325 --- /dev/null +++ b/apps/sim/lib/db/arrays.ts @@ -0,0 +1,15 @@ +import { type SQL, sql } from 'drizzle-orm' + +/** + * The pool uses fetch_types: false, so arrays must be constructed from scalar + * parameters. A JSON scalar keeps large sets below PostgreSQL's bind limit. + */ +export function textArrayLiteral(values: readonly string[]): SQL { + if (values.length > 1000) { + return sql`ARRAY(SELECT jsonb_array_elements_text(${JSON.stringify(values)}::text::jsonb))` + } + return sql`ARRAY[${sql.join( + values.map((value) => sql`${value}`), + sql`, ` + )}]::text[]` +} diff --git a/apps/sim/lib/knowledge/__integration__/workspace-lifecycle.integration.ts b/apps/sim/lib/knowledge/__integration__/workspace-lifecycle.integration.ts index 817f3903db8..5317a7da3da 100644 --- a/apps/sim/lib/knowledge/__integration__/workspace-lifecycle.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/workspace-lifecycle.integration.ts @@ -16,9 +16,6 @@ vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: null })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { clearCache: vi.fn().mockResolvedValue(undefined) }, })) -vi.mock('@/lib/workflows/lifecycle', () => ({ - archiveWorkflowsForWorkspace: vi.fn().mockResolvedValue(0), -})) import { createKnowledgeAclFixtureIds, diff --git a/apps/sim/lib/knowledge/access/predicate.ts b/apps/sim/lib/knowledge/access/predicate.ts index a316783db85..a7da9e6a427 100644 --- a/apps/sim/lib/knowledge/access/predicate.ts +++ b/apps/sim/lib/knowledge/access/predicate.ts @@ -11,6 +11,7 @@ import { user, } from '@sim/db/schema' import { type SQL, sql } from 'drizzle-orm' +import { textArrayLiteral } from '@/lib/db/arrays' import { EXTERNAL_GROUP_STALE_AFTER_MS } from '@/lib/knowledge/access/external-groups' import { SOURCE_ACL_MAX_AGE_MS } from '@/lib/knowledge/access/freshness' import { confluenceReaderGroupCondition } from '@/lib/knowledge/access/group-membership' @@ -279,17 +280,3 @@ function storedKnowledgeAccessCondition( export function aclOverlap(tokens: SQL): SQL { return sql`${document.acl} && ${tokens}` } - -/** - * The pool uses fetch_types: false, so arrays must be constructed from scalar - * parameters. A JSON scalar keeps large sets below PostgreSQL's bind limit. - */ -export function textArrayLiteral(values: readonly string[]): SQL { - if (values.length > 1000) { - return sql`ARRAY(SELECT jsonb_array_elements_text(${JSON.stringify(values)}::text::jsonb))` - } - return sql`ARRAY[${sql.join( - values.map((value) => sql`${value}`), - sql`, ` - )}]::text[]` -} diff --git a/apps/sim/lib/knowledge/connectors/member-observations.ts b/apps/sim/lib/knowledge/connectors/member-observations.ts index 2146bcac2f3..6cbc90c4121 100644 --- a/apps/sim/lib/knowledge/connectors/member-observations.ts +++ b/apps/sim/lib/knowledge/connectors/member-observations.ts @@ -26,8 +26,8 @@ import { type SQL, sql, } from 'drizzle-orm' +import { textArrayLiteral } from '@/lib/db/arrays' import type { DbOrTx } from '@/lib/db/types' -import { textArrayLiteral } from '@/lib/knowledge/access/predicate' import { walkReconciliationWindows } from '@/lib/knowledge/connectors/reconciliation-window' import { ACL_CHANGE_BATCH_SIZE, diff --git a/apps/sim/lib/knowledge/connectors/sync-persistence.ts b/apps/sim/lib/knowledge/connectors/sync-persistence.ts index b12ecba5a24..0759377789d 100644 --- a/apps/sim/lib/knowledge/connectors/sync-persistence.ts +++ b/apps/sim/lib/knowledge/connectors/sync-persistence.ts @@ -6,7 +6,7 @@ import { generateId } from '@sim/utils/id' import { truncateAtCodePoint } from '@sim/utils/string' import { and, eq, exists, inArray, isNull, lt, not, or, type SQL, sql } from 'drizzle-orm' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { textArrayLiteral } from '@/lib/knowledge/access/predicate' +import { textArrayLiteral } from '@/lib/db/arrays' import { EMPTY_ACL, validateMirroredDocumentAcl, diff --git a/apps/sim/lib/knowledge/search/vector-leg.ts b/apps/sim/lib/knowledge/search/vector-leg.ts index 2113961b7f9..19155646dc7 100644 --- a/apps/sim/lib/knowledge/search/vector-leg.ts +++ b/apps/sim/lib/knowledge/search/vector-leg.ts @@ -3,7 +3,7 @@ import { document, embedding, embeddingSearch } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' -import { textArrayLiteral } from '@/lib/knowledge/access/predicate' +import { textArrayLiteral } from '@/lib/db/arrays' import { runSearchQuery, type SearchBudget, diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 423de6a68bc..7e34ad16f89 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -31,9 +31,10 @@ import { } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { generateRestoreName } from '@/lib/core/utils/restore-name' +import { textArrayLiteral } from '@/lib/db/arrays' import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' -import { knowledgeAccessCondition, textArrayLiteral } from '@/lib/knowledge/access/predicate' +import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import type { KnowledgeAccessProvider } from '@/lib/knowledge/access/types' import { mirrorsSourceAcls } from '@/lib/knowledge/connectors/access-modes' import { diff --git a/apps/sim/lib/projects/README.md b/apps/sim/lib/projects/README.md index 88c3a330a9f..dc3deb8a198 100644 --- a/apps/sim/lib/projects/README.md +++ b/apps/sim/lib/projects/README.md @@ -2,7 +2,7 @@ A Project groups environments. An environment is an existing `workspace` record; there is no separate environment table. Every newly created Project starts with an environment. -Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`. Workspace creation always assigns a Project atomically. The API control defaults off and does not disable assignment. Existing assigned Projects always retain their lifecycle protections, including fork inheritance and disconnect behavior, even if activation is disabled. +Project APIs return HTTP 503 until the `projects` feature flag is on (AppConfig on hosted deployments; the `PROJECT_API_ENABLED` secret elsewhere). The flag defaults off and gates only the Project APIs: workspace creation always assigns a Project atomically, and assigned Projects keep their lifecycle protections (fork inheritance, disconnect) either way. ## Choose the creation flow @@ -12,9 +12,9 @@ Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`. | Existing workspace creation UI or caller | `POST /api/workspaces` | Creates a workspace and automatically creates its Project, preserving the existing workspace response. | | Create another environment by forking | Existing workspace fork operation | Inherits the source workspace's Project; unassigned legacy families remain unassigned until backfilled. | -Both POST endpoints are internal, session-authenticated APIs. `POST /api/projects` is not a public `/api/v2` endpoint and does not accept API-key principals. This foundation does not remove or deprecate existing workspace creation endpoints. +Both POST endpoints are internal, session-authenticated APIs. `POST /api/projects` is not a public `/api/v2` endpoint and does not accept API-key principals. Existing workspace creation endpoints remain supported. -Do not call both creation endpoints for one onboarding flow: each creates a new workspace and a new Project. Neither endpoint attaches a workspace to an existing Project. A general Project environment-creation endpoint is follow-up work. +Do not call both creation endpoints for one onboarding flow: each creates a new workspace and a new Project. Neither endpoint attaches a workspace to an existing Project. ## Create a Project and its first environment @@ -71,8 +71,12 @@ Existing callers can continue to use `createWorkspaceContract` and `POST /api/wo The workspace and its Project are created atomically. The generated Project name is `Support workspace - Project`; long names are bounded to 100 characters while retaining the suffix. The response remains `{ "workspace": ... }` with HTTP 200, without a new Project response wrapper. Call `GET /api/projects/by-workspace/[workspaceId]` when an existing workspace caller needs its authorized Project details. +## Archiving + +Archiving a workspace through the existing workspace deletion flow never strands an active Project: when the workspace is its Project's last active environment, the Project is archived in the same transaction. Account deletion applies the same rule to a Project whose surviving environments are all archived. `DELETE /api/projects/[id]` archives a Project and every environment together. + ## Server implementation The Project route calls `createProject` in `application/create-project.ts`. Existing workspace callers continue through their current creation paths. Both use the shared transaction primitive in `lib/workspaces/create.ts`; surface adapters must not independently commit Project and workspace creation. -Project descriptions and Project-scoped files are not part of this creation contract. Project-scoped files and a designated Project brief are follow-up work. +Project descriptions and Project-scoped files are not part of this creation contract. diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts index 876079dc5d7..7e219bc9d80 100644 --- a/apps/sim/lib/projects/__integration__/foundation.integration.ts +++ b/apps/sim/lib/projects/__integration__/foundation.integration.ts @@ -23,13 +23,16 @@ import { createWorkspaceApiKeyPrincipal, } from '@sim/testing/factories/principal.factory' import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, or, sql } from 'drizzle-orm' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { + removeUserFromOrganization, + transferOrganizationOwnership, +} from '@/lib/billing/organizations/membership' import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion' import { archiveProject, @@ -45,6 +48,7 @@ import { createProjectForWorkspace, lockProject, lockWorkspaceProject, + projectBackfillLockKey, splitForkProject, transferWorkspaceProjects, } from '@/lib/projects/membership' @@ -64,18 +68,25 @@ vi.hoisted(() => { process.env.ADMIN_API_KEY = 'project-fixture-admin-key' }) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +function setProjectsEnabled(enabled: boolean) { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( + async (flag) => enabled && flag === 'projects' + ) +} + beforeEach(() => { - vi.stubEnv('PROJECT_API_ENABLED', 'true') + setProjectsEnabled(true) }) const users: string[] = [] const organizations: string[] = [] -const environments: string[] = [] const request = { requestId: 'project-foundation-integration', headers: new Headers() } const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = [] -/** Exercises durable auth and lifecycle invariants against real Postgres, including concurrent writers. */ +/** Registers a test and records its status and duration in the suite report. */ function check(name: string, run: () => Promise) { it(name, async () => { const started = performance.now() @@ -125,7 +136,6 @@ async function fixture(org = true, count = 2) { let projectId = '' await db.transaction(async (tx) => { for (const [index, id] of ids.entries()) { - environments.push(id) await tx.insert(workspace).values({ id, name: `Environment ${index}`, @@ -164,6 +174,48 @@ async function fixture(org = true, count = 2) { return { ownerId, teammateId, outsiderId, organizationId, projectId, ids, owner, teammate } } +/** + * Waits until the operation under test is blocked behind `blockerPid`: a session whose + * current statement contains `waitingIn` (an advisory lock tag or row-lock clause), so an + * unrelated waiter cannot release the barrier early. + */ +async function waitUntilBlockedBy(blockerPid: number, waitingIn: string) { + await expect + .poll( + async () => + ( + await db.execute(sql` + SELECT 1 FROM pg_stat_activity + WHERE ${blockerPid} = ANY(pg_blocking_pids(pid)) + AND position(${waitingIn.toLowerCase()} in lower(query)) > 0 + `) + ).length, + { timeout: 2000, interval: 10 } + ) + .toBeGreaterThan(0) +} + +async function addOrganizationProject(organizationId: string, ownerId: string) { + const workspaceId = generateId() + const projectId = await db.transaction(async (tx) => { + await tx.insert(workspace).values({ + id: workspaceId, + name: 'Sibling environment', + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + }) + return createProjectForWorkspace(tx, { + workspaceId, + name: 'Sibling environment', + organizationId, + ownerId, + }) + }) + return { workspaceId, projectId } +} + async function addWorkflow(workspaceId: string, userId: string) { const id = generateId() const now = new Date() @@ -186,16 +238,11 @@ afterAll(async () => { process.env.PROJECT_FOUNDATION_REPORT_PATH ?? resolve('test-results/project-foundation.json') await mkdir(dirname(reportPath), { recursive: true }) await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) - if (environments.length) { - const memberships = await db - .select({ id: projectWorkspace.projectId }) - .from(projectWorkspace) - .where(inArray(projectWorkspace.workspaceId, environments)) - const ids = [...new Set(memberships.map((row) => row.id))] - await db.delete(projectWorkspace).where(inArray(projectWorkspace.workspaceId, environments)) - if (ids.length) await db.delete(project).where(inArray(project.id, ids)) - await db.delete(workspace).where(inArray(workspace.id, environments)) - } + if (users.length) + await db + .delete(workspace) + .where(or(inArray(workspace.ownerId, users), inArray(workspace.billedAccountUserId, users))) + if (users.length) await db.delete(project).where(inArray(project.ownerId, users)) if (organizations.length) await db.delete(organization).where(inArray(organization.id, organizations)) if (users.length) await db.delete(user).where(inArray(user.id, users)) @@ -205,7 +252,7 @@ describe('Project foundation at the database and application boundary', () => { check( 'workspace creation and fork/disconnect assign Projects while APIs remain disabled', async () => { - vi.stubEnv('PROJECT_API_ENABLED', 'false') + setProjectsEnabled(false) const f = await fixture(false, 1) const source = await db.transaction((tx) => createWorkspaceInTransaction(tx, { @@ -219,7 +266,6 @@ describe('Project foundation at the database and application boundary', () => { skipDefaultWorkflow: true, }) ) - environments.push(source.id) expect( await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, source.id)) ).toHaveLength(1) @@ -231,7 +277,6 @@ describe('Project foundation at the database and application boundary', () => { userId: f.ownerId, name: 'Legacy child', }) - environments.push(fork.workspace.id) expect( await db .select() @@ -249,8 +294,9 @@ describe('Project foundation at the database and application boundary', () => { 'Project operations remain unavailable until API activation with no partial creation', async () => { const f = await fixture(false, 1) - vi.stubEnv('PROJECT_API_ENABLED', 'false') + setProjectsEnabled(false) const input = { projectId: f.projectId } + const [before] = await db.select().from(project).where(eq(project.id, f.projectId)) const calls = [ () => createProject.execute({ @@ -283,70 +329,46 @@ describe('Project foundation at the database and application boundary', () => { expect( await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId)) ).toHaveLength(1) - const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) - expect(record.name).toBe('Environment 0 - Project') - expect(record.archivedAt).toBeNull() + expect(await db.select().from(project).where(eq(project.id, f.projectId))).toEqual([before]) } ) - check( - 'disabling activation preserves assigned fork membership and lifecycle protections', - async () => { - const f = await fixture(false, 1) - vi.stubEnv('PROJECT_API_ENABLED', 'false') - const parent = await getWorkspaceWithOwner(f.ids[0]) - if (!parent) throw new Error('Missing source fixture') - const fork = await createFork({ - source: parent, - policy: await getWorkspaceCreationPolicy({ userId: f.ownerId }), - userId: f.ownerId, - name: 'Assigned child', - }) - environments.push(fork.workspace.id) - const [membership] = await db - .select() - .from(projectWorkspace) - .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) - expect(membership.projectId).toBe(f.projectId) - await unlinkForkEdge({ parentWorkspaceId: f.ids[0], childWorkspaceId: fork.workspace.id }) - const [detached] = await db - .select() - .from(projectWorkspace) - .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) - expect(detached.projectId).not.toBe(f.projectId) - await expect( - archiveWorkspace(fork.workspace.id, { requestId: 'disabled-project-rollout' }) - ).rejects.toMatchObject({ code: 'conflict' }) - } - ) - - check('new workspaces receive Projects while Project APIs remain disabled', async () => { - vi.stubEnv('PROJECT_API_ENABLED', 'false') + check('a detached fork keeps its own Project, archived with its only environment', async () => { const f = await fixture(false, 1) - const created = await db.transaction((tx) => - createWorkspaceInTransaction(tx, { - userId: f.ownerId, - name: 'Writer activation', - organizationId: null, - observedOrganizationId: null, - governingPermissionGroupOrganizationId: null, - workspaceMode: 'personal', - billedAccountUserId: f.ownerId, - skipDefaultWorkflow: true, - }) - ) - environments.push(created.id) - expect( - await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, created.id)) - ).toHaveLength(1) + const parent = await getWorkspaceWithOwner(f.ids[0]) + if (!parent) throw new Error('Missing source fixture') + const fork = await createFork({ + source: parent, + policy: await getWorkspaceCreationPolicy({ userId: f.ownerId }), + userId: f.ownerId, + name: 'Assigned child', + }) + const [membership] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) + expect(membership.projectId).toBe(f.projectId) + await unlinkForkEdge({ parentWorkspaceId: f.ids[0], childWorkspaceId: fork.workspace.id }) + const [detached] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) + expect(detached.projectId).not.toBe(f.projectId) + await expect( + archiveWorkspace(fork.workspace.id, { requestId: 'detached-fork-archive' }) + ).resolves.toMatchObject({ archived: true }) + const [detachedProject] = await db + .select() + .from(project) + .where(eq(project.id, detached.projectId)) + expect(detachedProject.archivedAt).not.toBeNull() }) - check('legacy fork and disconnect refuse a partially assigned subtree', async () => { + check('fork refuses a partially assigned lineage', async () => { const f = await fixture(false, 3) await db .delete(projectWorkspace) .where(inArray(projectWorkspace.workspaceId, f.ids.slice(0, 2))) - vi.stubEnv('PROJECT_API_ENABLED', 'false') const parent = await getWorkspaceWithOwner(f.ids[1]) if (!parent) throw new Error('Missing source fixture') await expect( @@ -357,9 +379,6 @@ describe('Project foundation at the database and application boundary', () => { name: 'Invalid child', }) ).rejects.toMatchObject({ code: 'conflict' }) - await expect( - unlinkForkEdge({ parentWorkspaceId: f.ids[0], childWorkspaceId: f.ids[1] }) - ).rejects.toMatchObject({ code: 'conflict' }) const [child] = await db.select().from(workspace).where(eq(workspace.id, f.ids[1])) expect(child.forkedFromWorkspaceId).toBe(f.ids[0]) expect(await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))).toHaveLength( @@ -388,13 +407,9 @@ describe('Project foundation at the database and application boundary', () => { await Promise.race([read.promise, writer]) await db.transaction(async (tx) => { const [lock] = await tx.execute<{ acquired: boolean }>(sql` - SELECT pg_try_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0)) AS acquired + SELECT pg_try_advisory_xact_lock(hashtextextended(${projectBackfillLockKey(f.ids[0])}, 0)) AS acquired `) expect(lock.acquired).toBe(false) - const [unrelated] = await tx.execute<{ acquired: boolean }>(sql` - SELECT pg_try_advisory_xact_lock(hashtextextended('project-backfill:unrelated', 0)) AS acquired - `) - expect(unrelated.acquired).toBe(true) }) } finally { release.resolve() @@ -402,7 +417,7 @@ describe('Project foundation at the database and application boundary', () => { } await db.transaction(async (tx) => { await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))` + sql`SELECT pg_advisory_xact_lock(hashtextextended(${projectBackfillLockKey(f.ids[0])}, 0))` ) await createProjectForWorkspace(tx, { workspaceId: f.ids[0], @@ -424,13 +439,14 @@ describe('Project foundation at the database and application boundary', () => { const parent = await getWorkspaceWithOwner(f.ids[0]) if (!parent) throw new Error('Missing source fixture') const policy = await getWorkspaceCreationPolicy({ userId: f.ownerId }) - const locked = createDeferred() + const locked = createDeferred() const release = createDeferred() const backfill = db.transaction(async (tx) => { await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))` + sql`SELECT pg_advisory_xact_lock(hashtextextended(${projectBackfillLockKey(f.ids[0])}, 0))` ) - locked.resolve() + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + locked.resolve(connection.pid) await release.promise return createProjectForWorkspace(tx, { workspaceId: f.ids[0], @@ -445,27 +461,13 @@ describe('Project foundation at the database and application boundary', () => { policy, userId: f.ownerId, name: 'Concurrent child', - }).then((result) => { - environments.push(result.workspace.id) - return result }) - let blocked = false try { - for (let attempt = 0; attempt < 100; attempt++) { - const rows = await db.execute<{ waiting: boolean }>(sql`SELECT EXISTS ( - SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND mode = 'ShareLock' AND NOT granted - ) AS waiting`) - if (rows[0]?.waiting) { - blocked = true - break - } - await sleep(20) - } + await waitUntilBlockedBy(await locked.promise, "lock='project_backfill'") } finally { release.resolve() } const [projectId, result] = await Promise.all([backfill, fork]) - expect(blocked).toBe(true) const [membership] = await db .select() .from(projectWorkspace) @@ -496,7 +498,6 @@ describe('Project foundation at the database and application boundary', () => { }, request, }) - environments.push(result.initialEnvironment.id) const [created] = await db.select().from(project).where(eq(project.id, result.project.id)) expect(created).toMatchObject({ name: 'Customer support', @@ -639,44 +640,6 @@ describe('Project foundation at the database and application boundary', () => { } ) - check('workspace creation and forks commit exactly one Project membership', async () => { - const f = await fixture(false, 1) - const source = await db.transaction((tx) => - createWorkspaceInTransaction(tx, { - userId: f.ownerId, - name: 'New environment', - organizationId: null, - observedOrganizationId: null, - governingPermissionGroupOrganizationId: null, - workspaceMode: 'personal', - billedAccountUserId: f.ownerId, - skipDefaultWorkflow: true, - }) - ) - environments.push(source.id) - const before = await db - .select() - .from(projectWorkspace) - .where(eq(projectWorkspace.workspaceId, source.id)) - expect(before).toHaveLength(1) - const policy = await getWorkspaceCreationPolicy({ userId: f.ownerId }) - const parent = await getWorkspaceWithOwner(source.id) - if (!parent) throw new Error('Missing source fixture') - const fork = await createFork({ - source: parent, - policy, - userId: f.ownerId, - name: 'Child environment', - }) - environments.push(fork.workspace.id) - const child = await db - .select() - .from(projectWorkspace) - .where(eq(projectWorkspace.workspaceId, fork.workspace.id)) - expect(child).toHaveLength(1) - expect(child[0].projectId).toBe(before[0].projectId) - }) - check( 'a departing organization member transfers Project lifecycle ownership to the org owner', async () => { @@ -742,7 +705,7 @@ describe('Project foundation at the database and application boundary', () => { input, request, }) - ).rejects.toThrow() + ).rejects.toThrow('cannot perform operation') await expect( renameProject.execute({ principal: f.teammate, @@ -825,13 +788,21 @@ describe('Project foundation at the database and application boundary', () => { } ) - check('concurrent individual removals preserve the last active environment', async () => { + check('concurrent removal of every environment archives the Project', async () => { const f = await fixture(false) const results = await Promise.allSettled(f.ids.map((id) => archiveWorkspace(id, request))) - expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) - expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1) + expect(results).toMatchObject(f.ids.map(() => ({ status: 'fulfilled' }))) const rows = await db.select().from(workspace).where(inArray(workspace.id, f.ids)) - expect(rows.filter((row) => !row.archivedAt)).toHaveLength(1) + expect(rows.map((row) => row.archivedAt)).not.toContain(null) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.archivedAt).not.toBeNull() + }) + + check('removing one of several environments keeps the Project active', async () => { + const f = await fixture(false) + await archiveWorkspace(f.ids[0], request) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.archivedAt).toBeNull() }) check( @@ -876,18 +847,7 @@ describe('Project foundation at the database and application boundary', () => { {} ) try { - let waiting = false - for (let attempt = 0; attempt < 100; attempt++) { - const rows = await db.execute( - sql`SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid))` - ) - if (rows.length) { - waiting = true - break - } - await sleep(10) - } - expect(waiting).toBe(true) + await waitUntilBlockedBy(blocker, 'for share') } finally { release.resolve() await archive @@ -916,18 +876,7 @@ describe('Project foundation at the database and application boundary', () => { (error: unknown) => error ) try { - let waiting = false - for (let attempt = 0; attempt < 100; attempt++) { - const rows = await db.execute(sql` - SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid)) - `) - if (rows.length) { - waiting = true - break - } - await sleep(10) - } - expect(waiting).toBe(true) + await waitUntilBlockedBy(blocker, 'for share') } finally { release.resolve() await archive @@ -948,8 +897,15 @@ describe('Project foundation at the database and application boundary', () => { throw new Error('Abort compound archive') }) ).rejects.toThrow('Abort compound archive') - const before = await db.select().from(workspace).where(inArray(workspace.id, f.ids)) - expect(before.every((row) => row.archivedAt === null)).toBe(true) + const untouched = await db.select().from(workspace).where(inArray(workspace.id, f.ids)) + expect(untouched.map((row) => row.archivedAt)).toEqual([null, null]) + const activeWorkflows = await db + .select() + .from(workflow) + .where(inArray(workflow.id, workflowIds)) + expect(activeWorkflows).toMatchObject( + workflowIds.map(() => ({ archivedAt: null, isDeployed: true })) + ) const args = { principal: f.owner, input: { projectId: f.projectId }, request } await archiveProject.execute(args) await archiveProject.execute(args) @@ -1091,6 +1047,8 @@ describe('Project foundation at the database and application boundary', () => { const organizationId = source.organizationId if (!organizationId) throw new Error('Missing organization fixture') const destination = await fixture(true, 1) + const sibling = await addOrganizationProject(organizationId, source.ownerId) + const staying = await addOrganizationProject(organizationId, source.ownerId) const groupId = generateId() await db.insert(permissionGroup).values({ id: groupId, @@ -1098,17 +1056,28 @@ describe('Project foundation at the database and application boundary', () => { name: 'Source policy', createdBy: source.ownerId, isDefault: true, - config: { deniedPartialAccessProjectIssues: [source.projectId], hideTablesTab: true }, + config: { + deniedPartialAccessProjectIssues: [ + source.projectId, + staying.projectId, + sibling.projectId, + ], + hideTablesTab: true, + }, }) + const moving = [...source.ids, sibling.workspaceId] await db.transaction(async (tx) => { - await transferWorkspaceProjects(tx, source.ids, destination.organizationId) + await transferWorkspaceProjects(tx, moving, destination.organizationId) await tx .update(workspace) .set({ organizationId: destination.organizationId }) - .where(eq(workspace.id, source.ids[0])) + .where(inArray(workspace.id, moving)) }) const [group] = await db.select().from(permissionGroup).where(eq(permissionGroup.id, groupId)) - expect(group.config).toEqual({ deniedPartialAccessProjectIssues: [], hideTablesTab: true }) + expect(group.config).toEqual({ + deniedPartialAccessProjectIssues: [staying.projectId], + hideTablesTab: true, + }) const [moved] = await db.select().from(project).where(eq(project.id, source.projectId)) expect(moved).toMatchObject({ organizationId: destination.organizationId, @@ -1117,6 +1086,26 @@ describe('Project foundation at the database and application boundary', () => { } ) + check('organization ownership transfer moves the previous owner’s Projects', async () => { + const f = await fixture(true, 1) + if (!f.organizationId) throw new Error('Missing organization fixture') + await db.insert(member).values({ + id: generateId(), + organizationId: f.organizationId, + userId: f.teammateId, + role: 'member', + createdAt: new Date(), + }) + const result = await transferOrganizationOwnership({ + organizationId: f.organizationId, + currentOwnerUserId: f.ownerId, + newOwnerUserId: f.teammateId, + }) + expect(result).toMatchObject({ success: true }) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.ownerId).toBe(f.teammateId) + }) + check( 'organization deletion preserves Project identity and assigns its former owner explicitly', async () => { @@ -1142,7 +1131,7 @@ describe('Project foundation at the database and application boundary', () => { ) check( - 'account deletion preview reports a surviving Project losing its last active environment', + 'account deletion archives a surviving Project losing its last active environment', async () => { const f = await fixture(false) await db @@ -1164,14 +1153,11 @@ describe('Project foundation at the database and application boundary', () => { .where(eq(workspace.id, f.ids[1])) const plan = await getAccountDeletionPlan(f.ownerId) expect(plan.workspacesToDelete.map((row) => row.id)).toEqual([f.ids[0]]) - expect(plan.blockers).toEqual([ - { code: 'project_lifecycle', message: expect.stringContaining('Archive') }, - ]) - await expect( - db.transaction((tx) => prepareProjectsForAccountDeletion(tx, f.ownerId, [f.ids[0]])) - ).rejects.toMatchObject({ code: 'conflict' }) - await db.transaction((tx) => archiveProjectInTransaction(tx, f.projectId)) - expect((await getAccountDeletionPlan(f.ownerId)).blockers).toEqual([]) + expect(plan.blockers).toEqual([]) + await db.transaction((tx) => prepareProjectsForAccountDeletion(tx, f.ownerId, [f.ids[0]])) + const [record] = await db.select().from(project).where(eq(project.id, f.projectId)) + expect(record.archivedAt).not.toBeNull() + expect(record.ownerId).toBe(f.teammateId) } ) @@ -1216,18 +1202,7 @@ describe('Project foundation at the database and application boundary', () => { prepareProjectsForAccountDeletion(tx, f.ownerId, [f.ids[1]]) ) try { - let waiting = false - for (let attempt = 0; attempt < 100; attempt++) { - const rows = await db.execute( - sql`SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid))` - ) - if (rows.length) { - waiting = true - break - } - await sleep(10) - } - expect(waiting).toBe(true) + await waitUntilBlockedBy(blocker, "lock='project'") } finally { release.resolve() await unlink @@ -1257,18 +1232,7 @@ describe('Project foundation at the database and application boundary', () => { const blocker = await held.promise const ban = disableUserResources(f.ownerId) try { - let waiting = false - for (let attempt = 0; attempt < 100; attempt++) { - const rows = await db.execute( - sql`SELECT 1 FROM pg_stat_activity WHERE ${blocker} = ANY(pg_blocking_pids(pid))` - ) - if (rows.length) { - waiting = true - break - } - await sleep(10) - } - expect(waiting).toBe(true) + await waitUntilBlockedBy(blocker, "lock='project'") } finally { release.resolve() await transfer diff --git a/apps/sim/lib/projects/account-deletion.ts b/apps/sim/lib/projects/account-deletion.ts index 1831df0480d..7b0370d82f5 100644 --- a/apps/sim/lib/projects/account-deletion.ts +++ b/apps/sim/lib/projects/account-deletion.ts @@ -1,73 +1,73 @@ import { db } from '@sim/db' import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' +import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace' import { and, asc, eq, inArray, ne, or, sql } from 'drizzle-orm' -import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' -import { lockProject, lockProjectBackfillWrites } from '@/lib/projects/membership' +import { + lockProjectBackfillWrites, + lockProjects, + ProjectConflictError, +} from '@/lib/projects/membership' +/** Two indexed lookups; an `OR` around a membership subquery would scan every Project. */ async function loadRelatedProjects(executor: DbOrTx, userId: string, doomedWorkspaceIds: string[]) { + const doomedMemberships = doomedWorkspaceIds.length + ? await executor + .select({ projectId: projectWorkspace.projectId }) + .from(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, doomedWorkspaceIds)) + : [] return executor - .select({ id: project.id }) + .select() .from(project) .where( or( eq(project.ownerId, userId), - doomedWorkspaceIds.length - ? sql`${project.id} in ( - select ${projectWorkspace.projectId} from ${projectWorkspace} - where ${inArray(projectWorkspace.workspaceId, doomedWorkspaceIds)} - )` + doomedMemberships.length + ? inArray( + project.id, + doomedMemberships.map((row) => row.projectId) + ) : undefined ) ) .orderBy(asc(project.id)) } -interface ProjectDeletionDecision { - blocker?: string - remove?: boolean - ownerId?: string -} +type ProjectDeletionDecision = + | { blocker: string } + | { remove: true } + | { archive: boolean; ownerId?: string } -async function planProjectDeletion( +/** + * An org admin, else a teammate who administers every surviving environment. With `hold`, + * the successor's membership or grants stay share-locked until commit, so the handoff + * cannot land on someone demoted concurrently. + */ +async function findProjectSuccessor( executor: DbOrTx, record: typeof project.$inferSelect, userId: string, - doomed: Set -): Promise { - const members = await executor - .select({ id: workspace.id, archivedAt: workspace.archivedAt }) - .from(projectWorkspace) - .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) - .where(eq(projectWorkspace.projectId, record.id)) - const survivors = members.filter((row) => !doomed.has(row.id)) - if (!survivors.length) { - return record.organizationId || record.ownerId !== userId - ? { blocker: 'Account deletion cannot remove another owner’s Project' } - : { remove: true } - } - if (!record.archivedAt && survivors.every((row) => row.archivedAt)) { - return { - blocker: 'Archive the Project before deleting its last active environment with your account', - } - } - if (record.ownerId !== userId) return {} + survivorIds: string[], + hold: boolean +): Promise { if (record.organizationId) { - const [successor] = await executor + const adminQuery = executor .select({ userId: member.userId }) .from(member) .where( and( eq(member.organizationId, record.organizationId), ne(member.userId, userId), - inArray(member.role, ['owner', 'admin']) + inArray(member.role, ORG_ADMIN_ROLES) ) ) .orderBy(asc(member.userId)) .limit(1) - if (successor) return { ownerId: successor.userId } + const [admin] = await (hold ? adminQuery.for('share') : adminQuery) + if (admin) return admin.userId } - const [successor] = await executor + const [teammate] = await executor .select({ userId: permissions.userId }) .from(permissions) .where( @@ -75,18 +75,81 @@ async function planProjectDeletion( eq(permissions.entityType, 'workspace'), eq(permissions.permissionType, 'admin'), ne(permissions.userId, userId), - inArray( - permissions.entityId, - survivors.map((row) => row.id) - ) + inArray(permissions.entityId, survivorIds) ) ) .groupBy(permissions.userId) - .having(sql`count(*) = ${survivors.length}`) + .having(sql`count(*) = ${survivorIds.length}`) .orderBy(asc(permissions.userId)) .limit(1) - return successor - ? { ownerId: successor.userId } + if (!teammate || !hold) return teammate?.userId ?? null + const held = await executor + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.permissionType, 'admin'), + eq(permissions.userId, teammate.userId), + inArray(permissions.entityId, survivorIds) + ) + ) + .for('share') + return held.length === survivorIds.length ? teammate.userId : null +} + +interface ProjectEnvironment { + id: string + archivedAt: Date | null +} + +/** Every environment of `projectIds`, in one query, keyed by Project. */ +async function loadProjectEnvironments(executor: DbOrTx, projectIds: string[]) { + const rows = projectIds.length + ? await executor + .select({ + projectId: projectWorkspace.projectId, + id: workspace.id, + archivedAt: workspace.archivedAt, + }) + .from(projectWorkspace) + .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) + .where(inArray(projectWorkspace.projectId, projectIds)) + : [] + const byProject = new Map() + for (const { projectId, ...environment } of rows) { + const environments = byProject.get(projectId) + if (environments) environments.push(environment) + else byProject.set(projectId, [environment]) + } + return byProject +} + +async function planProjectDeletion( + executor: DbOrTx, + record: typeof project.$inferSelect, + members: ProjectEnvironment[], + userId: string, + doomed: Set, + hold: boolean +): Promise { + const survivors = members.filter((row) => !doomed.has(row.id)) + if (!survivors.length) { + return record.organizationId || record.ownerId !== userId + ? { blocker: 'Account deletion cannot remove another owner’s Project' } + : { remove: true } + } + const archive = !record.archivedAt && survivors.every((row) => row.archivedAt) + if (record.ownerId !== userId) return { archive } + const ownerId = await findProjectSuccessor( + executor, + record, + userId, + survivors.map((row) => row.id), + hold + ) + return ownerId + ? { archive, ownerId } : { blocker: 'Give a teammate admin access to every environment before deleting the Project owner’s account', @@ -99,13 +162,22 @@ export async function getProjectAccountDeletionBlockers( doomedWorkspaceIds: string[] ): Promise { const records = await loadRelatedProjects(db, userId, doomedWorkspaceIds) + const environments = await loadProjectEnvironments( + db, + records.map((record) => record.id) + ) const doomed = new Set(doomedWorkspaceIds) const blockers: string[] = [] - for (const { id } of records) { - const [record] = await db.select().from(project).where(eq(project.id, id)) - if (!record) continue - const decision = await planProjectDeletion(db, record, userId, doomed) - if (decision.blocker) blockers.push(decision.blocker) + for (const record of records) { + const decision = await planProjectDeletion( + db, + record, + environments.get(record.id) ?? [], + userId, + doomed, + false + ) + if ('blocker' in decision) blockers.push(decision.blocker) } return blockers } @@ -125,31 +197,46 @@ export async function prepareProjectsForAccountDeletion( ...ownedEnvironments.map((row) => row.id), ]) const locked = new Set() + let records: (typeof project.$inferSelect)[] for (;;) { - const records = await loadRelatedProjects(tx, userId, doomedWorkspaceIds) + records = await loadRelatedProjects(tx, userId, doomedWorkspaceIds) const pending = records.filter((row) => !locked.has(row.id)) if (!pending.length) break - for (const { id } of pending) { - await lockProject(tx, id) - locked.add(id) - } + await lockProjects( + tx, + pending.map((row) => row.id) + ) + for (const { id } of pending) locked.add(id) } - const records = await loadRelatedProjects(tx, userId, doomedWorkspaceIds) + const environments = await loadProjectEnvironments( + tx, + records.map((record) => record.id) + ) const doomed = new Set(doomedWorkspaceIds) - for (const { id } of records) { - await lockProject(tx, id) - const [record] = await tx.select().from(project).where(eq(project.id, id)) - if (!record) continue - const decision = await planProjectDeletion(tx, record, userId, doomed) - if (decision.blocker) throw new OrchestrationError('conflict', decision.blocker) - if (decision.remove) { - await tx.delete(projectWorkspace).where(eq(projectWorkspace.projectId, id)) - await tx.delete(project).where(eq(project.id, id)) + const now = new Date() + for (const record of records) { + const decision = await planProjectDeletion( + tx, + record, + environments.get(record.id) ?? [], + userId, + doomed, + true + ) + if ('blocker' in decision) throw new ProjectConflictError(decision.blocker) + if ('remove' in decision) { + await tx.delete(projectWorkspace).where(eq(projectWorkspace.projectId, record.id)) + await tx.delete(project).where(eq(project.id, record.id)) + continue } - if (!decision.ownerId) continue + if (!decision.archive && !decision.ownerId) continue await tx .update(project) - .set({ ownerId: decision.ownerId, updatedAt: new Date() }) - .where(eq(project.id, id)) + .set({ + ...(decision.archive ? { archivedAt: now } : {}), + ...(decision.ownerId ? { ownerId: decision.ownerId } : {}), + updatedAt: now, + }) + .where(eq(project.id, record.id)) } } diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts index 47ebc4f7b43..eab358ab472 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -1,52 +1,82 @@ import type { Principal, SessionPrincipal } from '@sim/auth/principal' -import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' +import { + member, + permissionGroup, + permissions, + project, + projectWorkspace, + workspace, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' -import { and, asc, eq, inArray } from 'drizzle-orm' +import { and, asc, eq, inArray, sql } from 'drizzle-orm' import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { textArrayLiteral } from '@/lib/db/arrays' import type { DbTransaction } from '@/lib/db/types' import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { resolveVerifiedUserAccessControlContext } from '@/lib/permission-groups/resolve.server' -import type { ProjectOperation } from '@/lib/projects/application/operations' +import { type ProjectOperation, projectOperations } from '@/lib/projects/application/operations' import { lockProject } from '@/lib/projects/membership' +const logger = createLogger('ProjectAuthorization') + export function requireProjectPrincipal( principal: Principal, - operation: Pick + operation: Pick ): asserts principal is SessionPrincipal { if (principal.kind !== 'session') throw new PrincipalKindAuthorizationError(principal.kind, operation.id) } -/** The complete environment set is loaded server-side; hidden environments never enter the result. */ -export async function authorizeProject( +type ProjectRecord = typeof project.$inferSelect + +interface ProjectEnvironmentAccess { + id: string + name: string + organizationId: string | null + archivedAt: Date | null + parentId: string | null + permission: string | null +} + +export interface ProjectAuthorizationInput { + organizationId?: string + workspaceId?: string +} + +/** + * `hold` locks the Project and the rows the decision reads until commit, for callers that + * act on it. `snapshot` takes no locks and relies on the caller's read-only snapshot. + */ +type ProjectAccessMode = 'hold' | 'snapshot' + +type ProjectAccess = Awaited> + +/** + * Loads the caller's org role and every environment with its grant for `records` in three + * queries, whatever their count. A snapshot read also learns, in one more query, which + * records any permission group restricts, so unrestricted ones skip per-environment policy. + */ +async function loadProjectAccess( tx: DbTransaction, - principal: SessionPrincipal, - operation: ProjectOperation, - input: { projectId: string; organizationId?: string; workspaceId?: string } + userId: string, + records: ProjectRecord[], + mode: ProjectAccessMode ) { - await lockProject(tx, input.projectId) - const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) - if ( - !record || - (input.organizationId !== undefined && input.organizationId !== record.organizationId) - ) { - throw new OrchestrationError('not_found', 'Project not found') - } - const [orgMember] = record.organizationId - ? await tx - .select({ role: member.role }) - .from(member) - .where( - and(eq(member.userId, principal.userId), eq(member.organizationId, record.organizationId)) - ) - .limit(1) - .for('share') + const lock = mode === 'hold' + const memberQuery = tx + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + const [membership] = records.some((record) => record.organizationId) + ? await (lock ? memberQuery.for('share') : memberQuery) : [] - const orgAdmin = isOrgAdminRole(orgMember?.role) const environments = await tx .select({ + projectId: projectWorkspace.projectId, id: workspace.id, name: workspace.name, organizationId: workspace.organizationId, @@ -55,30 +85,84 @@ export async function authorizeProject( }) .from(projectWorkspace) .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) - .where(eq(projectWorkspace.projectId, record.id)) + .where( + inArray( + projectWorkspace.projectId, + records.map((record) => record.id) + ) + ) .orderBy(asc(workspace.id)) - if (environments.some((row) => row.organizationId !== record.organizationId)) - throw new OrchestrationError('conflict', 'Project ownership needs reconciliation') - const grants = environments.length - ? await tx - .select({ id: permissions.entityId, permission: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, principal.userId), - inArray( - permissions.entityId, - environments.map((row) => row.id) - ) - ) + const grantQuery = tx + .select({ id: permissions.entityId, permission: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, userId), + inArray( + permissions.entityId, + environments.map((row) => row.id) ) - .orderBy(asc(permissions.entityId)) - .for('share') - : [] + ) + ) + .orderBy(asc(permissions.entityId)) + const grants = environments.length ? await (lock ? grantQuery.for('share') : grantQuery) : [] const grantsById = new Map(grants.map((row) => [row.id, row.permission])) - const rows = environments.map((row) => ({ ...row, permission: grantsById.get(row.id) ?? null })) - if (operation.access === 'issues' && record.organizationId) + const environmentsByProject = new Map() + for (const { projectId, ...row } of environments) { + const access = { ...row, permission: grantsById.get(row.id) ?? null } + const rows = environmentsByProject.get(projectId) + if (rows) rows.push(access) + else environmentsByProject.set(projectId, [access]) + } + const organizationIds = [ + ...new Set(records.flatMap((record) => (record.organizationId ? [record.organizationId] : []))), + ] + const restricted = + mode === 'snapshot' && organizationIds.length + ? new Set( + ( + await tx.execute<{ id: string }>(sql` + SELECT DISTINCT denied.id FROM ${permissionGroup}, + jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(${permissionGroup.config}->'deniedPartialAccessProjectIssues') = 'array' + THEN ${permissionGroup.config}->'deniedPartialAccessProjectIssues' + ELSE '[]'::jsonb + END + ) AS denied(id) + WHERE ${inArray(permissionGroup.organizationId, organizationIds)} + AND denied.id = ANY(${textArrayLiteral(records.map((record) => record.id))}) + `) + ).map((row) => row.id) + ) + : null + return { + /** Whether a permission group might restrict Issues for the Project; held reads check all. */ + mayRestrictIssues: (projectId: string) => restricted === null || restricted.has(projectId), + isOrgAdmin: (organizationId: string | null) => + organizationId !== null && + membership?.organizationId === organizationId && + isOrgAdminRole(membership.role), + environmentsFor: (projectId: string) => environmentsByProject.get(projectId) ?? [], + } +} + +/** Applies the access rules to one loaded Project; hidden environments never enter the result. */ +async function evaluateProjectAccess( + tx: DbTransaction, + principal: SessionPrincipal, + operation: ProjectOperation, + record: ProjectRecord, + access: ProjectAccess, + input: ProjectAuthorizationInput, + mode: ProjectAccessMode +) { + const orgAdmin = access.isOrgAdmin(record.organizationId) + const rows = access.environmentsFor(record.id) + if (rows.some((row) => row.organizationId !== record.organizationId)) + throw new OrchestrationError('conflict', 'Project ownership needs reconciliation') + if (mode === 'hold' && operation.access === 'issues' && record.organizationId) await acquirePermissionGroupOrgLock(tx, record.organizationId) const active = rows.filter((row) => !row.archivedAt) const visible = active.filter((row) => orgAdmin || row.permission !== null) @@ -94,7 +178,12 @@ export async function authorizeProject( 'Organization admin or admin access to every environment is required' ) let canUseIssues = !record.archivedAt && visible.length > 0 - if (canUseIssues && visible.length < active.length && record.organizationId) { + if ( + canUseIssues && + visible.length < active.length && + record.organizationId && + access.mayRestrictIssues(record.id) + ) { for (const environment of visible) { const { config } = await resolveVerifiedUserAccessControlContext( principal.userId, @@ -116,7 +205,6 @@ export async function authorizeProject( const visibleIds = new Set(visible.map((row) => row.id)) return { record, - environmentIds: rows.map((row) => row.id), canAdminister, canUseIssues, environments: visible.map((row) => ({ @@ -126,3 +214,64 @@ export async function authorizeProject( })), } } + +export type AuthorizedProject = Awaited> + +/** Authorizes one Project for `operation`; see {@link ProjectAccessMode} for locking. */ +export async function authorizeProject( + tx: DbTransaction, + principal: SessionPrincipal, + operation: ProjectOperation, + input: ProjectAuthorizationInput & { projectId: string }, + mode: ProjectAccessMode +): Promise { + if (mode === 'hold') await lockProject(tx, input.projectId) + const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) + if ( + !record || + (input.organizationId !== undefined && input.organizationId !== record.organizationId) + ) { + throw new OrchestrationError('not_found', 'Project not found') + } + const access = await loadProjectAccess(tx, principal.userId, [record], mode) + return evaluateProjectAccess(tx, principal, operation, record, access, input, mode) +} + +/** Authorizes a listed page of Projects in id order inside the caller's read-only snapshot. */ +export async function authorizeProjectsForRead( + tx: DbTransaction, + principal: SessionPrincipal, + projectIds: string[] +): Promise { + if (!projectIds.length) return [] + const records = await tx + .select() + .from(project) + .where(inArray(project.id, projectIds)) + .orderBy(asc(project.id)) + const access = await loadProjectAccess(tx, principal.userId, records, 'snapshot') + const authorized: AuthorizedProject[] = [] + for (const record of records) { + /** One inconsistent Project must not hide the rest of the caller's page. */ + if ( + access.environmentsFor(record.id).some((row) => row.organizationId !== record.organizationId) + ) { + logger.warn('Skipping a Project whose environments need ownership reconciliation', { + projectId: record.id, + }) + continue + } + authorized.push( + await evaluateProjectAccess( + tx, + principal, + projectOperations.list, + record, + access, + {}, + 'snapshot' + ) + ) + } + return authorized +} diff --git a/apps/sim/lib/projects/application/create-project.ts b/apps/sim/lib/projects/application/create-project.ts index 95baeca9441..162f6adf6e3 100644 --- a/apps/sim/lib/projects/application/create-project.ts +++ b/apps/sim/lib/projects/application/create-project.ts @@ -7,11 +7,12 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { refuseCapability } from '@/lib/permission-groups/capabilities' import { requireProjectPrincipal } from '@/lib/projects/application/authorization' import { projectOperations } from '@/lib/projects/application/operations' -import { type CreateProjectInput, createProjectInputSchema } from '@/lib/projects/create-input' +import type { CreateProjectInput } from '@/lib/projects/create-input' import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' import { createWorkspaceWithProjectInTransaction, emitWorkspaceCreatedPlatformEvent, + WORKSPACE_USER_FK_CONSTRAINTS, } from '@/lib/workspaces/create' import { getWorkspaceCreationPolicy, @@ -32,14 +33,8 @@ export const createProject: OperationUseCase< operation: projectOperations.create, async execute({ principal, input, request }) { requireProjectPrincipal(principal, projectOperations.create) - requireProjectApiEnabled() - const parsed = createProjectInputSchema.safeParse(input) - if (!parsed.success) - throw new OrchestrationError( - 'validation', - 'A scope, Project name and initial environment name are required' - ) - const { organizationId, name, initialEnvironment } = parsed.data + await requireProjectApiEnabled() + const { organizationId, name, initialEnvironment } = input const policy = await getWorkspaceCreationPolicy({ userId: principal.userId, activeOrganizationId: organizationId, @@ -80,21 +75,16 @@ export const createProject: OperationUseCase< ) if (getPostgresErrorCode(error) === '55P03') throw new OrchestrationError( - 'locked', + 'conflict', 'This organization is being updated; retry Project creation' ) if ( getPostgresErrorCode(error) === '23503' && - getPostgresConstraintName(error) === 'workspace_owner_id_user_id_fk' - ) - throw new OrchestrationError('unauthorized', 'Unauthorized') - if ( - getPostgresErrorCode(error) === '23503' && - getPostgresConstraintName(error) === 'workspace_billed_account_user_id_user_id_fk' + WORKSPACE_USER_FK_CONSTRAINTS.has(getPostgresConstraintName(error) ?? '') ) throw new OrchestrationError( 'conflict', - 'The billing account changed; retry Project creation' + 'The owning or billing account changed; retry Project creation' ) throw error } diff --git a/apps/sim/lib/projects/application/use-cases.ts b/apps/sim/lib/projects/application/use-cases.ts index eb7484471d8..98cfb3495fc 100644 --- a/apps/sim/lib/projects/application/use-cases.ts +++ b/apps/sim/lib/projects/application/use-cases.ts @@ -1,22 +1,27 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' -import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm' +import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace' +import { eq, inArray, sql } from 'drizzle-orm' import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' import type { OperationUseCase } from '@/lib/core/application/operation' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { authorizeProject, requireProjectPrincipal } from '@/lib/projects/application/authorization' +import { + type AuthorizedProject, + authorizeProject, + authorizeProjectsForRead, + type ProjectAuthorizationInput, + requireProjectPrincipal, +} from '@/lib/projects/application/authorization' import { projectOperations } from '@/lib/projects/application/operations' import { archiveProjectInTransaction, finishProjectArchive } from '@/lib/projects/lifecycle' import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' -interface ProjectInput { - projectId: string - organizationId?: string - workspaceId?: string -} -type ProjectContext = Awaited> -function presentProject(context: ProjectContext) { +type ProjectInput = ProjectAuthorizationInput & { projectId: string } +/** Reads see one consistent snapshot without locking the rows writers need. */ +const READ_SNAPSHOT = { isolationLevel: 'repeatable read', accessMode: 'read only' } as const + +function presentProject(context: AuthorizedProject) { return { ...context.record, environments: context.environments, @@ -32,14 +37,22 @@ export const getProject: OperationUseCase< operation: projectOperations.get, async execute({ principal, input }) { requireProjectPrincipal(principal, projectOperations.get) - requireProjectApiEnabled() - return db.transaction(async (tx) => ({ - project: presentProject(await authorizeProject(tx, principal, projectOperations.get, input)), - })) + await requireProjectApiEnabled() + return db.transaction( + async (tx) => ({ + project: presentProject( + await authorizeProject(tx, principal, projectOperations.get, input, 'snapshot') + ), + }), + READ_SNAPSHOT + ) }, } -/** Read-only capability probe. Issue mutations call authorizeProject inside their own transaction. */ +/** + * Read-only capability probe. Issue mutations call authorizeProject in `hold` mode + * inside their own transaction. + */ export const getProjectIssueAccess: OperationUseCase< typeof projectOperations.issues, ProjectInput, @@ -48,11 +61,17 @@ export const getProjectIssueAccess: OperationUseCase< operation: projectOperations.issues, async execute({ principal, input }) { requireProjectPrincipal(principal, projectOperations.issues) - requireProjectApiEnabled() + await requireProjectApiEnabled() return db.transaction(async (tx) => { - const context = await authorizeProject(tx, principal, projectOperations.issues, input) + const context = await authorizeProject( + tx, + principal, + projectOperations.issues, + input, + 'snapshot' + ) return { projectId: context.record.id } - }) + }, READ_SNAPSHOT) }, } @@ -64,43 +83,39 @@ export const listProjects: OperationUseCase< operation: projectOperations.list, async execute({ principal, input }) { requireProjectPrincipal(principal, projectOperations.list) - requireProjectApiEnabled() - if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) - throw new OrchestrationError('validation', 'Limit must be between 1 and 100') + await requireProjectApiEnabled() return db.transaction(async (tx) => { - const candidates = await tx - .select({ id: project.id }) - .from(project) - .where( - and( - isNull(project.archivedAt), - input.organizationId ? eq(project.organizationId, input.organizationId) : undefined, - input.cursor ? gt(project.id, input.cursor) : undefined, - sql`exists (select 1 from ${projectWorkspace} pw join ${workspace} w on w.id = pw.workspace_id - where pw.project_id = ${project.id} and w.archived_at is null and ( - exists (select 1 from ${permissions} pe where pe.entity_type = 'workspace' and pe.entity_id = w.id and pe.user_id = ${principal.userId}) - or exists (select 1 from ${member} m where m.organization_id = w.organization_id and m.user_id = ${principal.userId} and m.role in ('owner', 'admin')) - ))` - ) - ) - .orderBy(asc(project.id)) - .limit(input.limit + 1) - const page = candidates.slice(0, input.limit) - const projects = [] - for (const row of page) - projects.push( - presentProject( - await authorizeProject(tx, principal, projectOperations.list, { - projectId: row.id, - organizationId: input.organizationId, - }) - ) + /** Driven from the caller's grants and admin organization, so cost tracks their reach. */ + const candidates = await tx.execute<{ id: string }>(sql` + WITH accessible AS ( + SELECT ${permissions.entityId} AS workspace_id FROM ${permissions} + WHERE ${permissions.userId} = ${principal.userId} + AND ${permissions.entityType} = 'workspace' + UNION + SELECT ${workspace.id} FROM ${member} + JOIN ${workspace} ON ${workspace.organizationId} = ${member.organizationId} + WHERE ${member.userId} = ${principal.userId} AND ${inArray(member.role, ORG_ADMIN_ROLES)} ) + SELECT DISTINCT ${projectWorkspace.projectId} AS id + FROM accessible + JOIN ${projectWorkspace} ON ${projectWorkspace.workspaceId} = accessible.workspace_id + JOIN ${workspace} + ON ${workspace.id} = accessible.workspace_id AND ${workspace.archivedAt} IS NULL + JOIN ${project} + ON ${project.id} = ${projectWorkspace.projectId} AND ${project.archivedAt} IS NULL + WHERE TRUE + ${input.organizationId ? sql`AND ${project.organizationId} = ${input.organizationId}` : sql``} + ${input.cursor ? sql`AND ${projectWorkspace.projectId} > ${input.cursor}` : sql``} + ORDER BY 1 + LIMIT ${input.limit + 1} + `) + const page = candidates.slice(0, input.limit).map((row) => row.id) + const projects = await authorizeProjectsForRead(tx, principal, page) return { - projects, - nextCursor: candidates.length > input.limit ? (page.at(-1)?.id ?? null) : null, + projects: projects.map(presentProject), + nextCursor: candidates.length > input.limit ? (page.at(-1) ?? null) : null, } - }) + }, READ_SNAPSHOT) }, } @@ -112,12 +127,10 @@ export const renameProject: OperationUseCase< operation: projectOperations.rename, async execute({ principal, input, request }) { requireProjectPrincipal(principal, projectOperations.rename) - requireProjectApiEnabled() - const name = input.name.trim() - if (!name || name.length > 100) - throw new OrchestrationError('validation', 'Project name must contain 1–100 characters') + await requireProjectApiEnabled() + const { name } = input const result = await db.transaction(async (tx) => { - const context = await authorizeProject(tx, principal, projectOperations.rename, input) + const context = await authorizeProject(tx, principal, projectOperations.rename, input, 'hold') if (context.record.archivedAt) throw new OrchestrationError('conflict', 'Project is archived') if (context.record.name === name) return { context, changed: false } await tx @@ -154,9 +167,15 @@ export const archiveProject: OperationUseCase< operation: projectOperations.archive, async execute({ principal, input, request }) { requireProjectPrincipal(principal, projectOperations.archive) - requireProjectApiEnabled() + await requireProjectApiEnabled() const result = await db.transaction(async (tx) => { - const context = await authorizeProject(tx, principal, projectOperations.archive, input) + const context = await authorizeProject( + tx, + principal, + projectOperations.archive, + input, + 'hold' + ) const effects = await archiveProjectInTransaction(tx, context.record.id) return { context, effects } }) @@ -190,7 +209,7 @@ export const getWorkspaceProject: OperationUseCase< operation: projectOperations.get, async execute({ principal, input }) { requireProjectPrincipal(principal, projectOperations.get) - requireProjectApiEnabled() + await requireProjectApiEnabled() return db.transaction(async (tx) => { const [membership] = await tx .select({ projectId: projectWorkspace.projectId }) @@ -200,12 +219,18 @@ export const getWorkspaceProject: OperationUseCase< if (!membership) throw new OrchestrationError('not_found', 'Project not found') return { project: presentProject( - await authorizeProject(tx, principal, projectOperations.get, { - projectId: membership.projectId, - workspaceId: input.workspaceId, - }) + await authorizeProject( + tx, + principal, + projectOperations.get, + { + projectId: membership.projectId, + workspaceId: input.workspaceId, + }, + 'snapshot' + ) ), } - }) + }, READ_SNAPSHOT) }, } diff --git a/apps/sim/lib/projects/create-input.ts b/apps/sim/lib/projects/create-input.ts index 4eb91f72df2..c342feb4d5c 100644 --- a/apps/sim/lib/projects/create-input.ts +++ b/apps/sim/lib/projects/create-input.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' export const createProjectInputSchema = z.object({ - organizationId: z.string().trim().min(1).nullable(), + organizationId: z.string().trim().pipe(organizationIdSchema).nullable(), name: z.string().trim().min(1).max(100), initialEnvironment: z.object({ name: z.string().trim().min(1).max(100) }), }) diff --git a/apps/sim/lib/projects/lifecycle.ts b/apps/sim/lib/projects/lifecycle.ts index d089f7c2e38..2fe08df4890 100644 --- a/apps/sim/lib/projects/lifecycle.ts +++ b/apps/sim/lib/projects/lifecycle.ts @@ -1,66 +1,40 @@ -import { - project, - projectWorkspace, - workflow, - workflowMcpServer, - workflowMcpTool, - workspace, -} from '@sim/db/schema' -import { and, asc, eq, isNull } from 'drizzle-orm' +import { project, projectWorkspace, workspace } from '@sim/db/schema' +import { asc, eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbTransaction } from '@/lib/db/types' import { lockProject } from '@/lib/projects/membership' -import { archiveWorkflowInTransaction, finishWorkflowArchive } from '@/lib/workflows/lifecycle' -import { archiveWorkspaceInTransaction, finishWorkspaceArchive } from '@/lib/workspaces/lifecycle' +import { + archiveEnvironmentInTransaction, + type EnvironmentArchiveEffects, + finishEnvironmentArchive, +} from '@/lib/workspaces/lifecycle' /** All durable archive state commits together; external notifications follow the commit. */ -export async function archiveProjectInTransaction(tx: DbTransaction, projectId: string) { +export async function archiveProjectInTransaction( + tx: DbTransaction, + projectId: string +): Promise { await lockProject(tx, projectId) const [record] = await tx.select().from(project).where(eq(project.id, projectId)) if (!record) throw new OrchestrationError('not_found', 'Project not found') const now = record.archivedAt ?? new Date() - const members = await tx - .select({ id: projectWorkspace.workspaceId }) + const environments = await tx + .select({ id: workspace.id }) .from(projectWorkspace) + .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) .where(eq(projectWorkspace.projectId, projectId)) - .orderBy(asc(projectWorkspace.workspaceId)) - const workflows: { id: string; workspaceId: string; serverIds: string[] }[] = [] - const environments: { id: string; serverIds: string[] }[] = [] - for (const { id: workspaceId } of members) { - await tx - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .for('update') - const rows = await tx - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) - .orderBy(asc(workflow.id)) - for (const row of rows) { - const servers = await tx - .select({ id: workflowMcpTool.serverId }) - .from(workflowMcpTool) - .where(eq(workflowMcpTool.workflowId, row.id)) - await archiveWorkflowInTransaction(tx, row.id, now) - workflows.push({ id: row.id, workspaceId, serverIds: servers.map((server) => server.id) }) - } - const servers = await tx - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where(eq(workflowMcpServer.workspaceId, workspaceId)) - await archiveWorkspaceInTransaction(tx, workspaceId, now) - environments.push({ id: workspaceId, serverIds: servers.map((server) => server.id) }) - } + .orderBy(asc(workspace.id)) + .for('no key update', { of: workspace }) + const effects: EnvironmentArchiveEffects[] = [] + for (const { id } of environments) + effects.push(await archiveEnvironmentInTransaction(tx, id, now)) await tx.update(project).set({ archivedAt: now, updatedAt: now }).where(eq(project.id, projectId)) - return { workflows, environments } + return effects } export async function finishProjectArchive( - effects: Awaited>, + effects: EnvironmentArchiveEffects[], requestId: string ): Promise { - for (const row of effects.workflows) - await finishWorkflowArchive(row.id, row.workspaceId, row.serverIds, { requestId }) - for (const row of effects.environments) await finishWorkspaceArchive(row.id, row.serverIds) + for (const environment of effects) await finishEnvironmentArchive(environment, requestId) } diff --git a/apps/sim/lib/projects/membership.ts b/apps/sim/lib/projects/membership.ts index ed93c8059f0..536f95133a6 100644 --- a/apps/sim/lib/projects/membership.ts +++ b/apps/sim/lib/projects/membership.ts @@ -1,9 +1,72 @@ import { permissionGroup, project, projectWorkspace, workspace } from '@sim/db/schema' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, ne, notInArray, sql } from 'drizzle-orm' +import { compareStrings, truncateAtCodePoint } from '@sim/utils/string' +import { and, asc, eq, inArray, isNull, notInArray, type SQL, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { DbOrTx, DbTransaction } from '@/lib/db/types' +import { + acquireAdvisoryXactLock, + acquireAdvisoryXactLocks, + tryAcquireAdvisoryXactLocks, +} from '@/lib/db/advisory-locks' +import { textArrayLiteral } from '@/lib/db/arrays' +import type { DbTransaction } from '@/lib/db/types' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' + +const PROJECT_LOCK_TIMEOUT_MS = 5_000 + +/** A Project lifecycle rule or lock refused the change; callers may map it to their own error. */ +export class ProjectConflictError extends OrchestrationError { + constructor(message: string) { + super('conflict', message) + this.name = 'ProjectConflictError' + } +} + +/** + * Waits in `acquire` are bounded by {@link PROJECT_LOCK_TIMEOUT_MS} and a timeout or + * deadlock becomes a retryable Project conflict. The caller's own `lock_timeout` is + * restored afterwards, so the bound never leaks into the work done under the locks. + */ +async function withProjectLockTimeout( + tx: DbTransaction, + message: string, + acquire: () => Promise +): Promise { + const [setting] = await tx.execute<{ previous: string }>( + sql`SELECT current_setting('lock_timeout') AS previous` + ) + await tx.execute(sql`SELECT set_config('lock_timeout', ${`${PROJECT_LOCK_TIMEOUT_MS}ms`}, true)`) + let result: T + try { + result = await acquire() + } catch (error) { + const code = getPostgresErrorCode(error) + if (code === '55P03' || code === '40P01') throw new ProjectConflictError(message) + throw error + } + await tx.execute(sql`SELECT set_config('lock_timeout', ${setting?.previous ?? '0'}, true)`) + return result +} + +/** + * The advisory key the Project membership backfill holds exclusively per workspace while it + * assigns it; writers take it shared. Every holder locks in code-unit order of workspace id + * (`ORDER BY id COLLATE "C"` in SQL) so the two sides cannot deadlock. + */ +export function projectBackfillLockKey(workspaceId: string): string { + return `project-backfill:${workspaceId}` +} + +const BACKFILL_RUNNING = 'Project backfill is running; retry the operation' +const PROJECT_CHANGING = 'Project is changing; retry the operation' + +function acquireBackfillWriteLocks(tx: DbTransaction, workspaceIds: string[]) { + const locks = [...new Set(workspaceIds)] + .sort(compareStrings) + .map((id) => ({ key: projectBackfillLockKey(id), shared: true })) + return acquireAdvisoryXactLocks(tx, 'project_backfill', locks) +} /** Shared per-environment gate keeps membership absence reads stable during SQL backfill. */ export async function lockProjectBackfillWrites( @@ -11,39 +74,47 @@ export async function lockProjectBackfillWrites( workspaceIds: string[] ): Promise { if (!workspaceIds.length) return - await tx.execute(sql`SET LOCAL lock_timeout = '5s'`) - try { - await tx.execute(sql` - SELECT pg_advisory_xact_lock_shared(hashtextextended('project-backfill:' || id, 0)) - FROM (SELECT DISTINCT unnest(ARRAY[${sql.join( - workspaceIds.map((id) => sql`${id}`), - sql`, ` - )}]::text[]) AS id ORDER BY id) ids - `) - } catch (error) { - if (getPostgresErrorCode(error) === '55P03') - throw new OrchestrationError('conflict', 'Project backfill is running; retry the operation') - throw error - } + await withProjectLockTimeout(tx, BACKFILL_RUNNING, () => + acquireBackfillWriteLocks(tx, workspaceIds) + ) } /** Canonical Project mutex; membership and lifecycle writers hold it until commit. */ export async function lockProject(tx: DbTransaction, projectId: string): Promise { - await tx.execute(sql`SELECT set_config('lock_timeout', '5000ms', true)`) - try { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project:${projectId}`}, 0))` - ) - } catch (error) { - if (getPostgresErrorCode(error) === '55P03') - throw new OrchestrationError('conflict', 'Project is changing; retry the operation') - throw error - } + await withProjectLockTimeout(tx, PROJECT_CHANGING, () => + acquireAdvisoryXactLock(tx, 'project', projectLockKey(projectId)) + ) +} + +/** Takes several Project mutexes in code-unit id order, the order every multi-lock holder uses. */ +export async function lockProjects(tx: DbTransaction, projectIds: string[]): Promise { + if (!projectIds.length) return + const locks = [...new Set(projectIds)] + .sort(compareStrings) + .map((id) => ({ key: projectLockKey(id), shared: false })) + await withProjectLockTimeout(tx, PROJECT_CHANGING, () => + acquireAdvisoryXactLocks(tx, 'project', locks) + ) +} + +function projectLockKey(projectId: string): string { + return `project:${projectId}` } function generatedProjectName(workspaceName: string): string { const suffix = ' - Project' - return `${(workspaceName.trim() || 'Untitled').slice(0, 100 - suffix.length)}${suffix}` + return `${truncateAtCodePoint(workspaceName.trim() || 'Untitled', 100 - suffix.length, '')}${suffix}` +} + +/** Selects `workspaceId` and every fork descendant, archived ones included, as `descendants`. */ +function forkSubtree(workspaceId: string): SQL { + return sql` + WITH RECURSIVE descendants AS ( + SELECT id, name, owner_id, archived_at FROM workspace WHERE id = ${workspaceId} + UNION + SELECT w.id, w.name, w.owner_id, w.archived_at + FROM workspace w JOIN descendants d ON w.forked_from_workspace_id = d.id + )` } export async function createProjectForWorkspace( @@ -53,7 +124,6 @@ export async function createProjectForWorkspace( name: string organizationId: string | null ownerId: string - archivedAt?: Date | null projectName?: string } ): Promise { @@ -63,7 +133,6 @@ export async function createProjectForWorkspace( name: input.projectName ?? generatedProjectName(input.name), organizationId: input.organizationId, ownerId: input.ownerId, - archivedAt: input.archivedAt ?? null, }) await tx.insert(projectWorkspace).values({ projectId: id, workspaceId: input.workspaceId }) return id @@ -71,95 +140,93 @@ export async function createProjectForWorkspace( /** Returns null only for a legacy workspace awaiting the SQL backfill. */ export async function lockWorkspaceProject(tx: DbTransaction, workspaceId: string) { - await lockProjectBackfillWrites(tx, [workspaceId]) - const [membership] = await tx - .select() - .from(projectWorkspace) - .where(eq(projectWorkspace.workspaceId, workspaceId)) - .limit(1) - if (!membership) return null - await lockProject(tx, membership.projectId) - const [current] = await tx - .select({ project }) - .from(projectWorkspace) - .innerJoin(project, eq(project.id, projectWorkspace.projectId)) - .where(eq(projectWorkspace.workspaceId, workspaceId)) - .limit(1) - if (!current || current.project.id !== membership.projectId) { - throw new OrchestrationError('conflict', 'Project membership changed; retry the operation') - } - return current.project + return withProjectLockTimeout(tx, PROJECT_CHANGING, async () => { + await acquireBackfillWriteLocks(tx, [workspaceId]) + const [membership] = await tx + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + .limit(1) + if (!membership) return null + await acquireAdvisoryXactLock(tx, 'project', projectLockKey(membership.projectId)) + const [current] = await tx + .select({ project }) + .from(projectWorkspace) + .innerJoin(project, eq(project.id, projectWorkspace.projectId)) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + .limit(1) + if (!current || current.project.id !== membership.projectId) { + throw new ProjectConflictError('Project membership changed; retry the operation') + } + return current.project + }) } +/** + * Locks the parent's Project for a new fork; null means a legacy parent awaiting + * backfill whose subtree must still be unassigned. Refuses an archived Project. + */ export async function requireForkProject(tx: DbTransaction, parentWorkspaceId: string) { const parent = await lockWorkspaceProject(tx, parentWorkspaceId) if (!parent) { await requireUnassignedForkSubtree(tx, parentWorkspaceId) return null } - if (parent.archivedAt) throw new OrchestrationError('conflict', 'Cannot fork an archived Project') + if (parent.archivedAt) throw new ProjectConflictError('Cannot fork an archived Project') return parent } /** Legacy fallback must not hide partially assigned descendants. Caller holds the lineage lock. */ async function requireUnassignedForkSubtree(tx: DbTransaction, workspaceId: string): Promise { - const descendants = await tx.execute<{ id: string }>(sql` - WITH RECURSIVE descendants AS ( - SELECT id FROM workspace WHERE id = ${workspaceId} - UNION - SELECT w.id FROM workspace w JOIN descendants d ON w.forked_from_workspace_id = d.id - ) SELECT id FROM descendants - `) - if (!descendants.length) return - await lockProjectBackfillWrites( - tx, - descendants.map((row) => row.id) + const descendants = await tx.execute<{ id: string }>( + sql`${forkSubtree(workspaceId)} SELECT id FROM descendants` ) + if (!descendants.length) return + const ids = descendants.map((row) => row.id) + await lockProjectBackfillWrites(tx, ids) const rows = await tx .select({ id: projectWorkspace.workspaceId }) .from(projectWorkspace) - .where( - inArray( - projectWorkspace.workspaceId, - descendants.map((row) => row.id) - ) - ) + .where(inArray(projectWorkspace.workspaceId, ids)) .limit(1) if (rows.length) - throw new OrchestrationError( - 'conflict', - 'Fork descendants need Project membership reconciliation' - ) + throw new ProjectConflictError('Fork descendants need Project membership reconciliation') } -/** Individual removal cannot leave an active Project without an active environment. */ -export async function requireRemainingProjectEnvironment( +/** + * Keeps an active Project from outliving its environments: archiving its last active + * environment archives it too. Returns whether it did. Caller holds the Project lock. + */ +export async function archiveProjectWithLastEnvironment( tx: DbTransaction, - workspaceId: string -): Promise { - const owner = await lockWorkspaceProject(tx, workspaceId) - if (!owner) return - const [remaining] = await tx - .select({ id: workspace.id }) - .from(projectWorkspace) - .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) + projectId: string, + workspaceId: string, + now: Date +): Promise { + const archived = await tx + .update(project) + .set({ archivedAt: now, updatedAt: now }) .where( and( - eq(projectWorkspace.projectId, owner.id), - ne(workspace.id, workspaceId), - isNull(workspace.archivedAt) + eq(project.id, projectId), + isNull(project.archivedAt), + sql`NOT EXISTS ( + SELECT 1 FROM ${projectWorkspace} + JOIN ${workspace} ON ${workspace.id} = ${projectWorkspace.workspaceId} + WHERE ${projectWorkspace.projectId} = ${projectId} + AND ${workspace.id} <> ${workspaceId} + AND ${workspace.archivedAt} IS NULL + )` ) ) - .limit(1) - if (!remaining && !owner.archivedAt) { - throw new OrchestrationError( - 'conflict', - 'The last active environment cannot be removed. Archive the Project instead.' - ) - } + .returning({ id: project.id }) + return archived.length > 0 } -/** Called before clearing the edge, under the existing lineage lock. */ +/** + * Moves the detached subtree into a new Project and returns its id; null for a legacy + * unassigned subtree. Called before clearing the edge, under the existing lineage lock. + */ export async function splitForkProject( tx: DbTransaction, workspaceId: string @@ -169,29 +236,23 @@ export async function splitForkProject( await requireUnassignedForkSubtree(tx, workspaceId) return null } - if (owner.archivedAt) - throw new OrchestrationError('conflict', 'Cannot disconnect an archived Project') + if (owner.archivedAt) throw new ProjectConflictError('Cannot disconnect an archived Project') const rows = await tx.execute<{ id: string name: string owner_id: string archived_at: Date | null project_id: string | null - }>(sql` - WITH RECURSIVE descendants AS ( - SELECT id, name, owner_id, archived_at FROM workspace WHERE id = ${workspaceId} - UNION - SELECT w.id, w.name, w.owner_id, w.archived_at FROM workspace w JOIN descendants d ON w.forked_from_workspace_id = d.id - ) SELECT d.*, pw.project_id FROM descendants d LEFT JOIN project_workspace pw ON pw.workspace_id = d.id + }>(sql`${forkSubtree(workspaceId)} + SELECT d.*, pw.project_id FROM descendants d LEFT JOIN project_workspace pw ON pw.workspace_id = d.id `) if (rows.some((row) => row.project_id !== owner.id)) - throw new OrchestrationError( - 'conflict', + throw new ProjectConflictError( 'Fork descendants need Project membership reconciliation before disconnecting' ) const root = rows.find((row) => row.id === workspaceId) if (!root || rows.every((row) => row.archived_at)) - throw new OrchestrationError('conflict', 'A new Project needs an active environment') + throw new ProjectConflictError('A new Project needs an active environment') const ids = rows.map((row) => row.id) const [remaining] = await tx .select({ id: workspace.id }) @@ -206,8 +267,7 @@ export async function splitForkProject( ) .limit(1) if (!remaining) - throw new OrchestrationError( - 'conflict', + throw new ProjectConflictError( 'Disconnecting would remove the last active environment from this Project' ) const id = generateId() @@ -224,6 +284,7 @@ export async function splitForkProject( and(eq(projectWorkspace.projectId, owner.id), inArray(projectWorkspace.workspaceId, ids)) ) if (owner.organizationId) { + await acquirePermissionGroupOrgLock(tx, owner.organizationId) await tx.execute(sql` UPDATE ${permissionGroup} SET config = jsonb_set(config, '{deniedPartialAccessProjectIssues}', @@ -235,7 +296,11 @@ export async function splitForkProject( return id } -/** Ownership changes include the complete Project; never silently split Project-wide resources. */ +/** + * Ownership changes include the complete Project; never silently split Project-wide + * resources. Callers hold the organization mutation lock of every organization the + * Projects leave, which serializes the permission-group edit with group mutations. + */ export async function transferWorkspaceProjects( tx: DbTransaction, workspaceIds: string[], @@ -249,48 +314,83 @@ export async function transferWorkspaceProjects( .from(projectWorkspace) .where(inArray(projectWorkspace.workspaceId, workspaceIds)) .orderBy(asc(projectWorkspace.projectId)) + if (!owners.length) return + const projectIds = owners.map((row) => row.id) + await tryLockProjects(tx, projectIds) const selected = new Set(workspaceIds) - for (const owner of owners) { - await tryLockProject(tx, owner.id) - const members = await tx - .select({ id: projectWorkspace.workspaceId }) - .from(projectWorkspace) - .where(eq(projectWorkspace.projectId, owner.id)) - if (members.some((row) => !selected.has(row.id))) { - throw new OrchestrationError( - 'conflict', - 'Move all environments in the Project together, or disconnect the fork first' - ) - } - const [current] = await tx - .select({ organizationId: project.organizationId }) - .from(project) - .where(eq(project.id, owner.id)) - if (current?.organizationId && current.organizationId !== organizationId) { - await tx.execute(sql` - UPDATE ${permissionGroup} - SET config = jsonb_set(config, '{deniedPartialAccessProjectIssues}', - (config->'deniedPartialAccessProjectIssues') - ${owner.id}), updated_at = now() - WHERE organization_id = ${current.organizationId} - AND config->'deniedPartialAccessProjectIssues' ? ${owner.id} - `) - } - await tx - .update(project) - .set({ - organizationId, - ownerId, - updatedAt: new Date(), - }) - .where(eq(project.id, owner.id)) + const members = await tx + .select({ id: projectWorkspace.workspaceId }) + .from(projectWorkspace) + .where(inArray(projectWorkspace.projectId, projectIds)) + if (members.some((row) => !selected.has(row.id))) { + throw new ProjectConflictError( + 'Move all environments in the Project together, or disconnect the fork first' + ) + } + const current = await tx + .select({ id: project.id, organizationId: project.organizationId }) + .from(project) + .where(inArray(project.id, projectIds)) + const leavingByOrganization = new Map() + for (const row of current) { + if (!row.organizationId || row.organizationId === organizationId) continue + const leaving = leavingByOrganization.get(row.organizationId) + if (leaving) leaving.push(row.id) + else leavingByOrganization.set(row.organizationId, [row.id]) } + for (const [previousOrganizationId, leaving] of leavingByOrganization) { + const ids = textArrayLiteral(leaving) + await tx.execute(sql` + UPDATE ${permissionGroup} + SET config = jsonb_set(config, '{deniedPartialAccessProjectIssues}', + (config->'deniedPartialAccessProjectIssues') - ${ids}), updated_at = now() + WHERE organization_id = ${previousOrganizationId} + AND config->'deniedPartialAccessProjectIssues' ?| ${ids} + `) + } + await tx + .update(project) + .set({ organizationId, ownerId, updatedAt: new Date() }) + .where(inArray(project.id, projectIds)) } -/** Existing ownership paths can hold workspace rows first; refuse contention instead of inverting locks. */ -export async function tryLockProject(tx: DbOrTx, projectId: string): Promise { - const [lock] = await tx.execute<{ acquired: boolean }>( - sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${`project:${projectId}`}, 0)) AS acquired` - ) - if (!lock?.acquired) - throw new OrchestrationError('conflict', 'Project is changing; retry the ownership change') +/** + * Moves the organization Projects `fromUserId` owns to `toUserId`, alongside the + * workspace ownership change that `workspaceIds` names. + */ +export async function reassignOrganizationProjects( + tx: DbTransaction, + input: { organizationId: string; fromUserId: string; toUserId: string; workspaceIds: string[] } +): Promise { + await lockProjectBackfillWrites(tx, input.workspaceIds) + const owned = await tx + .select({ id: project.id }) + .from(project) + .where( + and(eq(project.organizationId, input.organizationId), eq(project.ownerId, input.fromUserId)) + ) + .orderBy(asc(project.id)) + if (!owned.length) return + const ids = owned.map((row) => row.id) + await tryLockProjects(tx, ids) + await tx + .update(project) + .set({ ownerId: input.toUserId, updatedAt: new Date() }) + .where( + and( + inArray(project.id, ids), + eq(project.organizationId, input.organizationId), + eq(project.ownerId, input.fromUserId) + ) + ) +} + +/** + * Existing ownership paths can hold workspace rows first; refuse contention instead + * of inverting locks. + */ +async function tryLockProjects(tx: DbTransaction, projectIds: string[]): Promise { + const keys = projectIds.map(projectLockKey) + if (!(await tryAcquireAdvisoryXactLocks(tx, 'project', keys))) + throw new ProjectConflictError('Project is changing; retry the ownership change') } diff --git a/apps/sim/lib/projects/rollout.server.ts b/apps/sim/lib/projects/rollout.server.ts index 1f6e24962e4..758f5c0b963 100644 --- a/apps/sim/lib/projects/rollout.server.ts +++ b/apps/sim/lib/projects/rollout.server.ts @@ -1,4 +1,4 @@ -import { envBoolean, getEnv } from '@/lib/core/config/env' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { HttpError } from '@/lib/core/utils/http-error' class ProjectUnavailableError extends HttpError { @@ -8,6 +8,6 @@ class ProjectUnavailableError extends HttpError { } } -export function requireProjectApiEnabled(): void { - if (!(envBoolean(getEnv('PROJECT_API_ENABLED')) ?? false)) throw new ProjectUnavailableError() +export async function requireProjectApiEnabled(): Promise { + if (!(await isFeatureEnabled('projects'))) throw new ProjectUnavailableError() } diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index ce66288f5d4..f748152778f 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -15,6 +15,7 @@ import { workspace as workspaceTable, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { formatQuotedNameList } from '@sim/utils/string' import { and, eq, gt, inArray, isNotNull, isNull, lte, ne, notExists, or, sql } from 'drizzle-orm' import type { @@ -817,7 +818,23 @@ export async function deleteUserAccount(userId: string): Promise { - await tx.delete(webhookPathClaim).where(eq(webhookPathClaim.workflowId, workflowId)) +export async function releaseWebhookPathClaims( + tx: DbOrTx, + workflowIds: readonly string[] +): Promise { + if (workflowIds.length === 0) return + await tx.delete(webhookPathClaim).where(inArray(webhookPathClaim.workflowId, workflowIds)) } /** diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index 67d51ff0095..0c78a3c3ed6 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -3,7 +3,6 @@ import { apiKey, chat, folder as folderTable, - projectWorkspace, webhook, workflow, workflowDeploymentVersion, @@ -109,7 +108,7 @@ export async function archiveWorkflow( .from(workflowMcpTool) .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) - await db.transaction((tx) => archiveWorkflowInTransaction(tx, workflowId, now)) + await db.transaction((tx) => archiveWorkflowsInTransaction(tx, [workflowId], now)) await finishWorkflowArchive( workflowId, @@ -300,33 +299,8 @@ export async function disableUserResources(userId: string): Promise { .from(workspace) .where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt))) - const { archiveProjectInTransaction, finishProjectArchive } = await import( - '@/lib/projects/lifecycle' - ) - const { lockWorkspaceProject } = await import('@/lib/projects/membership') - const processed = new Set() for (const row of ownedWorkspaces) { - if (processed.has(row.id)) continue - const archived = await db.transaction(async (tx) => { - const record = await lockWorkspaceProject(tx, row.id) - if (!record) return null - const active = await tx - .select({ id: workspace.id, ownerId: workspace.ownerId }) - .from(projectWorkspace) - .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) - .where(and(eq(projectWorkspace.projectId, record.id), isNull(workspace.archivedAt))) - .orderBy(workspace.id) - .for('no key update', { of: workspace }) - if (!active.length || !active.every((entry) => entry.ownerId === userId)) return null - return archiveProjectInTransaction(tx, record.id) - }) - if (archived) { - for (const entry of archived.environments) processed.add(entry.id) - await finishProjectArchive(archived, requestId) - } else { - await archiveWorkspace(row.id, { requestId, expectedOwnerId: userId }) - processed.add(row.id) - } + await archiveWorkspace(row.id, { requestId, expectedOwnerId: userId }) } await db.delete(apiKey).where(eq(apiKey.userId, userId)) @@ -335,14 +309,18 @@ export async function disableUserResources(userId: string): Promise { ) } -/** Durable archive state shared by single-workflow and compound Project archival. */ -export async function archiveWorkflowInTransaction( +/** + * Durable archive state shared by single-workflow and compound Project archival. Each + * statement covers the whole batch, so the round trips do not grow with its size. + */ +export async function archiveWorkflowsInTransaction( tx: DbTransaction, - workflowId: string, + workflowIds: readonly string[], now: Date ): Promise { - await supersedeInFlightDeploymentOperations(tx, workflowId) - await releaseWebhookPathClaims(tx, workflowId) + if (workflowIds.length === 0) return + await supersedeInFlightDeploymentOperations(tx, workflowIds) + await releaseWebhookPathClaims(tx, workflowIds) await tx .update(workflowSchedule) @@ -353,7 +331,9 @@ export async function archiveWorkflowInTransaction( nextRunAt: null, lastQueuedAt: null, }) - .where(and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt))) + .where( + and(inArray(workflowSchedule.workflowId, workflowIds), isNull(workflowSchedule.archivedAt)) + ) await tx .update(webhook) @@ -362,7 +342,7 @@ export async function archiveWorkflowInTransaction( updatedAt: now, isActive: false, }) - .where(and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt))) + .where(and(inArray(webhook.workflowId, workflowIds), isNull(webhook.archivedAt))) await tx .update(chat) @@ -371,7 +351,7 @@ export async function archiveWorkflowInTransaction( updatedAt: now, isActive: false, }) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .where(and(inArray(chat.workflowId, workflowIds), isNull(chat.archivedAt))) await tx .update(workflowMcpTool) @@ -379,14 +359,21 @@ export async function archiveWorkflowInTransaction( archivedAt: now, updatedAt: now, }) - .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + .where( + and(inArray(workflowMcpTool.workflowId, workflowIds), isNull(workflowMcpTool.archivedAt)) + ) await tx .update(workflowDeploymentVersion) .set({ isActive: false, }) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + .where( + and( + inArray(workflowDeploymentVersion.workflowId, workflowIds), + eq(workflowDeploymentVersion.isActive, true) + ) + ) await tx .update(workflow) @@ -396,7 +383,7 @@ export async function archiveWorkflowInTransaction( isDeployed: false, isPublicApi: false, }) - .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) + .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) } /** Best-effort external notifications run only after durable archive state commits. */ @@ -419,13 +406,9 @@ export async function finishWorkflowArchive( await cleanupExternalWebhooksForWorkflow(workflowId, options.requestId) - if (workspaceId && mcpPubSub && serverIds.length > 0) { - const uniqueServerIds = [...new Set(serverIds)] - for (const serverId of uniqueServerIds) { - mcpPubSub.publishWorkflowToolsChanged({ - serverId, - workspaceId: workspaceId, - }) + if (workspaceId && mcpPubSub) { + for (const serverId of new Set(serverIds)) { + mcpPubSub.publishWorkflowToolsChanged({ serverId, workspaceId }) } } } diff --git a/apps/sim/lib/workflows/persistence/deployment-operations.ts b/apps/sim/lib/workflows/persistence/deployment-operations.ts index 6bac6ef19fa..4d1ffca15c1 100644 --- a/apps/sim/lib/workflows/persistence/deployment-operations.ts +++ b/apps/sim/lib/workflows/persistence/deployment-operations.ts @@ -716,14 +716,15 @@ export async function recordDeploymentOperationRetry( } /** - * Supersedes every in-flight operation for a workflow. Must run inside the + * Supersedes every in-flight operation for `workflowIds`. Must run inside the * undeploy/archive transaction so a queued preparation cannot activate a * version after the user explicitly took the workflow offline. */ export async function supersedeInFlightDeploymentOperations( executor: DbOrTx, - workflowId: string + workflowIds: readonly string[] ): Promise { + if (workflowIds.length === 0) return const now = new Date() await executor .update(workflowDeploymentOperation) @@ -734,7 +735,7 @@ export async function supersedeInFlightDeploymentOperations( }) .where( and( - eq(workflowDeploymentOperation.workflowId, workflowId), + inArray(workflowDeploymentOperation.workflowId, workflowIds), inArray(workflowDeploymentOperation.status, IN_FLIGHT_STATUSES) ) ) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 09d486204a6..f05c08fc4fb 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -20,7 +20,7 @@ import { import { and, eq } from 'drizzle-orm' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' -import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' +import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { remapConditionIdsInSubBlocks, remapVariableIdsInSubBlocks, @@ -211,19 +211,17 @@ export async function duplicateWorkflow( // A duplicate is a new workflow, so it takes the workspace's fork-sync policy rather // than inheriting the source's participation, and starts unlocked like any new one. - await tx.insert(workflow).values( - await buildNewWorkflowRow(tx, { - id: newWorkflowId, - userId, - workspaceId: targetWorkspaceId, - folderId: targetFolderId, - sortOrder, - name: deduplicatedName, - description: description || source.description, - now, - variables, - }) - ) + await insertNewWorkflowRow(tx, { + id: newWorkflowId, + userId, + workspaceId: targetWorkspaceId, + folderId: targetFolderId, + sortOrder, + name: deduplicatedName, + description: description || source.description, + now, + variables, + }) // Copy all blocks from source workflow with new IDs const sourceBlocks = await tx diff --git a/apps/sim/lib/workflows/persistence/new-workflow-row.ts b/apps/sim/lib/workflows/persistence/new-workflow-row.ts index 4b7324a8611..6c820d2eb5c 100644 --- a/apps/sim/lib/workflows/persistence/new-workflow-row.ts +++ b/apps/sim/lib/workflows/persistence/new-workflow-row.ts @@ -1,6 +1,5 @@ -import { type workflow, workspace } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' -import type { DbOrTx, DbTransaction } from '@/lib/db/types' +import { workflow } from '@sim/db/schema' +import type { DbTransaction } from '@/lib/db/types' import { lockActiveWorkspace } from '@/lib/workspaces/active-workspace' interface NewWorkflowRowInput { @@ -15,33 +14,14 @@ interface NewWorkflowRowInput { now?: Date } -/** - * The workspace's `forkSyncNewWorkflowsExcluded` policy: whether a workflow created now - * starts outside fork sync. - * - * `false` for an archived or missing workspace: a wrongly-synced workflow is visible and - * fixable in the Forks list, while a wrongly-excluded one silently stops syncing. - */ -export async function readForkSyncNewWorkflowsExcluded( - executor: DbOrTx, - workspaceId: string -): Promise { - const [row] = await executor - .select({ excluded: workspace.forkSyncNewWorkflowsExcluded }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - return row?.excluded ?? false -} - /** * The insert row for a genuinely new workflow - created, duplicated, imported, or seeded as * a starter - so every such path takes the workspace's fork-sync policy rather than the * column default. A fork or promote copy is not new and is written by - * `copyWorkflowStateIntoTarget` instead. + * `copyWorkflowStateIntoTarget` instead. Share-locks the workspace and refuses an archived one. */ export async function buildNewWorkflowRow(executor: DbTransaction, input: NewWorkflowRowInput) { - const workspace = await lockActiveWorkspace(executor, input.workspaceId) + const target = await lockActiveWorkspace(executor, input.workspaceId) const now = input.now ?? new Date() return { id: input.id, @@ -57,6 +37,10 @@ export async function buildNewWorkflowRow(executor: DbTransaction, input: NewWor isDeployed: false, runCount: 0, variables: input.variables ?? {}, - forkSyncExcluded: workspace.forkSyncNewWorkflowsExcluded, + forkSyncExcluded: target.forkSyncNewWorkflowsExcluded, } satisfies typeof workflow.$inferInsert } + +export async function insertNewWorkflowRow(tx: DbTransaction, input: NewWorkflowRowInput) { + await tx.insert(workflow).values(await buildNewWorkflowRow(tx, input)) +} diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 3d9c6a14ee5..f2be891b240 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -972,10 +972,10 @@ export async function undeployWorkflow(params: { .where(eq(workflowDeploymentVersion.workflowId, workflowId)) const deploymentVersionIds = deploymentVersions.map((version) => version.id) - await supersedeInFlightDeploymentOperations(dbCtx, workflowId) + await supersedeInFlightDeploymentOperations(dbCtx, [workflowId]) const { deleteSchedulesForWorkflow } = await import('@/lib/workflows/schedules/deploy') await deleteSchedulesForWorkflow(workflowId, dbCtx) - await releaseWebhookPathClaims(dbCtx, workflowId) + await releaseWebhookPathClaims(dbCtx, [workflowId]) await dbCtx .update(workflowDeploymentVersion) diff --git a/apps/sim/lib/workspaces/active-workspace.ts b/apps/sim/lib/workspaces/active-workspace.ts index f506aa02ae8..8edb9856444 100644 --- a/apps/sim/lib/workspaces/active-workspace.ts +++ b/apps/sim/lib/workspaces/active-workspace.ts @@ -1,11 +1,14 @@ import { workspace } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { DbOrTx } from '@/lib/db/types' +import type { DbTransaction } from '@/lib/db/types' -/** Transactional resource creation holds this row through insertion so archival cannot overtake it. */ -export async function lockActiveWorkspace(executor: DbOrTx, workspaceId: string) { - const [record] = await executor +/** + * Transactional resource creation and restore hold this row through the write so + * archival cannot overtake it. + */ +export async function lockActiveWorkspace(tx: DbTransaction, workspaceId: string) { + const [record] = await tx .select({ archivedAt: workspace.archivedAt, forkSyncNewWorkflowsExcluded: workspace.forkSyncNewWorkflowsExcluded, diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index 9ad81035fd1..f0a98b5773d 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -30,7 +30,6 @@ import { planHasFixedSeatCap, resolveSeatCapacity, } from '@/lib/billing/validation/seat-management' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { addOutboxEventSourceOperationId, enqueueOrReschedulePendingOutboxEvent, @@ -42,7 +41,7 @@ import type { DbOrTx } from '@/lib/db/types' import { getInvitationById, isInvitationExpired } from '@/lib/invitations/core' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { PENDING_INVITATION_UNIQUE_INDEX, sendInvitationEmail } from '@/lib/invitations/send' -import { transferWorkspaceProjects } from '@/lib/projects/membership' +import { ProjectConflictError, transferWorkspaceProjects } from '@/lib/projects/membership' import { invalidateWorkspaceTableLimitsCache } from '@/lib/table/billing' import { deleteCustomBlock } from '@/lib/workflows/custom-blocks/operations' import { @@ -1501,7 +1500,7 @@ export async function moveWorkspaceToOrganization(params: { }) break } catch (error) { - if (error instanceof OrchestrationError && error.code === 'conflict') { + if (error instanceof ProjectConflictError) { throw new WorkspaceMoveError(error.message, 'project-conflict') } if (error instanceof InvitationSetChangedError) { diff --git a/apps/sim/lib/workspaces/create.ts b/apps/sim/lib/workspaces/create.ts index 4b1549bd0ae..b764d7982bd 100644 --- a/apps/sim/lib/workspaces/create.ts +++ b/apps/sim/lib/workspaces/create.ts @@ -1,14 +1,13 @@ import { db } from '@sim/db' -import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' +import { permissions, type WorkspaceMode, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { PlatformEvents } from '@/lib/core/telemetry' import type { DbTransaction } from '@/lib/db/types' import { createProjectForWorkspace } from '@/lib/projects/membership' -import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' +import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { getWorkspaceInvitePolicy, @@ -20,7 +19,7 @@ import { } from '@/lib/workspaces/policy' /** Foreign keys from `workspace` to `user`; a violation means the acting user's row is gone. */ -const WORKSPACE_USER_FK_CONSTRAINTS = new Set([ +export const WORKSPACE_USER_FK_CONSTRAINTS = new Set([ 'workspace_owner_id_user_id_fk', 'workspace_billed_account_user_id_user_id_fk', ]) @@ -88,9 +87,11 @@ export interface TransactionalCreateWorkspaceParams extends CreateWorkspaceParam * The caller supplies the creation-policy snapshot. This function revalidates * that snapshot — including the `workspace.create` capability under the * permission-group advisory lock — before inserting the workspace, owner - * permission and optional starter workflow atomically. + * permission, its Project and optional starter workflow atomically. A caller + * creating an explicit Project names it; otherwise the name derives from the + * workspace. */ -async function createWorkspaceRecordsInTransaction( +export async function createWorkspaceWithProjectInTransaction( tx: DbTransaction, { projectName, @@ -165,17 +166,15 @@ async function createWorkspaceRecordsInTransaction( await tx.insert(permissions).values(permissionRows) if (defaultWorkflowArtifacts) { - await tx.insert(workflow).values( - await buildNewWorkflowRow(tx, { - id: workflowId, - userId, - workspaceId, - folderId: null, - name: 'default-agent', - description: 'Your first workflow - start building here!', - now, - }) - ) + await insertNewWorkflowRow(tx, { + id: workflowId, + userId, + workspaceId, + folderId: null, + name: 'default-agent', + description: 'Your first workflow - start building here!', + now, + }) await saveWorkflowToNormalizedTables( workflowId, defaultWorkflowArtifacts.workflowState, @@ -204,21 +203,11 @@ async function createWorkspaceRecordsInTransaction( } } -/** Explicit Project creation always commits its first environment in the same transaction. */ -export async function createWorkspaceWithProjectInTransaction( - tx: DbTransaction, - params: TransactionalCreateWorkspaceParams & { projectName?: string } -): Promise<{ projectId: string; workspace: CreatedWorkspace }> { - requireProjectApiEnabled() - return createWorkspaceRecordsInTransaction(tx, params) -} - -/** Preserves the workspace-only result for existing creation callers. */ export async function createWorkspaceInTransaction( tx: DbTransaction, params: TransactionalCreateWorkspaceParams ): Promise { - return (await createWorkspaceRecordsInTransaction(tx, params)).workspace + return (await createWorkspaceWithProjectInTransaction(tx, params)).workspace } /** Creates a workspace through the canonical lock-and-insert transaction. */ diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index 08fa60e21c9..d6c88cb377c 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -8,30 +8,53 @@ import { knowledgeConnector, mcpServers, userTableDefinitions, + workflow, workflowMcpServer, + workflowMcpTool, workspace, workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { chunkArray } from '@sim/utils/helpers' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import type { DbTransaction } from '@/lib/db/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { mcpService } from '@/lib/mcp/service' -import { lockWorkspaceProject, requireRemainingProjectEnvironment } from '@/lib/projects/membership' -import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle' +import { archiveProjectWithLastEnvironment, lockWorkspaceProject } from '@/lib/projects/membership' +import { archiveWorkflowsInTransaction, finishWorkflowArchive } from '@/lib/workflows/lifecycle' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceLifecycle') +/** Bounds each batched workflow archive statement's parameter list. */ +const WORKFLOW_ARCHIVE_BATCH_SIZE = 1_000 +/** Bounds concurrent post-commit notifications; each is best-effort and catches its own errors. */ +const ARCHIVE_NOTIFICATION_CONCURRENCY = 8 + +/** What an environment archive must announce once its transaction commits. */ +export interface EnvironmentArchiveEffects { + workspaceId: string + workflows: { id: string; serverIds: string[] }[] + serverIds: string[] +} + interface ArchiveWorkspaceOptions { requestId: string expectedOwnerId?: string } +interface ArchiveWorkspaceResult { + archived: boolean + workspaceName?: string + /** The Project archived with its last active environment, for the caller's audit. */ + archivedProject?: { id: string; name: string } +} + export async function archiveWorkspace( workspaceId: string, options: ArchiveWorkspaceOptions -): Promise<{ archived: boolean; workspaceName?: string }> { +): Promise { const workspaceRecord = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) if (!workspaceRecord) { @@ -40,48 +63,112 @@ export async function archiveWorkspace( /** Retrying deletion also archives children left active by an older or incomplete deletion. */ const now = workspaceRecord.archivedAt ?? new Date() - const workflowMcpServerIds = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where(eq(workflowMcpServer.workspaceId, workspaceId)) - const archived = await db.transaction(async (tx) => { - if (options.expectedOwnerId) { - await lockWorkspaceProject(tx, workspaceId) - const [current] = await tx - .select({ ownerId: workspace.ownerId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .for('no key update') - if (!current || current.ownerId !== options.expectedOwnerId) return false + const outcome = await db.transaction(async (tx) => { + const owningProject = await lockWorkspaceProject(tx, workspaceId) + /** Waits out in-flight workflow creation and restore, so their rows are archived too. */ + const [current] = await tx + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .for('no key update') + if (!current) return null + if (options.expectedOwnerId && current.ownerId !== options.expectedOwnerId) return null + const projectArchived = + owningProject !== null && + (await archiveProjectWithLastEnvironment(tx, owningProject.id, workspaceId, now)) + return { + effects: await archiveEnvironmentInTransaction(tx, workspaceId, now), + archivedProject: projectArchived + ? { id: owningProject.id, name: owningProject.name } + : undefined, } - await requireRemainingProjectEnvironment(tx, workspaceId) - await archiveWorkspaceInTransaction(tx, workspaceId, now) - return true }) - if (!archived) return { archived: false } - - await archiveWorkflowsForWorkspace(workspaceId, options) + if (!outcome) return { archived: false } logger.info(`[${options.requestId}] Archived workspace ${workspaceId}`) - await finishWorkspaceArchive( - workspaceId, - workflowMcpServerIds.map((server) => server.id) - ) + await finishEnvironmentArchive(outcome.effects, options.requestId) return { archived: !workspaceRecord.archivedAt, workspaceName: workspaceRecord.name, + archivedProject: outcome.archivedProject, } } -/** Durable environment archive changes; callers own Project minimum-environment checks. */ -export async function archiveWorkspaceInTransaction( +/** + * Archives an environment and its active workflows in the caller's transaction, batching + * the workflow statements; callers own the Project lifecycle. Announce the returned + * effects with {@link finishEnvironmentArchive} after commit. + */ +export async function archiveEnvironmentInTransaction( tx: DbTransaction, workspaceId: string, now: Date +): Promise { + const workflows: EnvironmentArchiveEffects['workflows'] = [] + const active = await tx + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) + .orderBy(asc(workflow.id)) + for (const batch of chunkArray( + active.map((row) => row.id), + WORKFLOW_ARCHIVE_BATCH_SIZE + )) { + const tools = await tx + .select({ workflowId: workflowMcpTool.workflowId, serverId: workflowMcpTool.serverId }) + .from(workflowMcpTool) + .where(and(inArray(workflowMcpTool.workflowId, batch), isNull(workflowMcpTool.archivedAt))) + const serverIdsByWorkflow = new Map() + for (const tool of tools) { + const serverIds = serverIdsByWorkflow.get(tool.workflowId) + if (serverIds) serverIds.push(tool.serverId) + else serverIdsByWorkflow.set(tool.workflowId, [tool.serverId]) + } + await archiveWorkflowsInTransaction(tx, batch, now) + for (const id of batch) workflows.push({ id, serverIds: serverIdsByWorkflow.get(id) ?? [] }) + } + const serverIds = await archiveWorkspaceRecordsInTransaction(tx, workspaceId, now) + return { workspaceId, workflows, serverIds } +} + +/** + * Announces a committed environment archive. Every step is best-effort and isolated, so + * one failed notification never skips the rest. + */ +export async function finishEnvironmentArchive( + effects: EnvironmentArchiveEffects, + requestId: string ): Promise { + const { workspaceId } = effects + await mapWithConcurrency(effects.workflows, ARCHIVE_NOTIFICATION_CONCURRENCY, (row) => + finishWorkflowArchive(row.id, workspaceId, row.serverIds, { requestId }).catch((error) => + logger.warn(`[${requestId}] Post-archive notification failed for workflow ${row.id}`, { + error, + }) + ) + ) + await mcpService.clearCache(workspaceId).catch(() => undefined) + if (!mcpPubSub) return + for (const serverId of effects.serverIds) { + try { + mcpPubSub.publishWorkflowToolsChanged({ serverId, workspaceId }) + } catch (error) { + logger.warn(`[${requestId}] MCP tools-changed publish failed for server ${serverId}`, { + error, + }) + } + } +} + +/** The workspace row and its non-workflow resources; returns the deployed MCP server ids. */ +async function archiveWorkspaceRecordsInTransaction( + tx: DbTransaction, + workspaceId: string, + now: Date +): Promise { await tx .update(knowledgeBase) .set({ @@ -161,6 +248,11 @@ export async function archiveWorkspaceInTransaction( .delete(apiKey) .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) + /** Every server is announced, so a retry still invalidates; only live ones are stamped. */ + const servers = await tx + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where(eq(workflowMcpServer.workspaceId, workspaceId)) await tx .update(workflowMcpServer) .set({ @@ -168,7 +260,7 @@ export async function archiveWorkspaceInTransaction( isPublic: false, updatedAt: now, }) - .where(eq(workflowMcpServer.workspaceId, workspaceId)) + .where(and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt))) await tx .update(mcpServers) @@ -186,16 +278,5 @@ export async function archiveWorkspaceInTransaction( updatedAt: now, }) .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) -} - -/** Refreshes derived MCP state after the archive transaction commits. */ -export async function finishWorkspaceArchive( - workspaceId: string, - serverIds: string[] -): Promise { - await mcpService.clearCache(workspaceId).catch(() => undefined) - if (mcpPubSub) { - for (const serverId of serverIds) - mcpPubSub.publishWorkflowToolsChanged({ serverId, workspaceId }) - } + return servers.map((server) => server.id) } diff --git a/knip.jsonc b/knip.jsonc index e68838874c9..68c63368a0e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -45,8 +45,6 @@ // Generated contracts mirror their source of truth; regenerating them must not // trip the unused-export ratchet, and hand edits would be overwritten. "lib/mothership/generated/**": ["exports", "types", "duplicates"], - // API conventions require exported named wire schemas and aliases, including before client adoption. - "lib/api/contracts/projects.ts": ["exports", "types"], "sandbox-tasks/index.ts": ["files"], "components/mcp/index.ts": ["files"], "triggers/quickbooks/index.ts": ["files"], diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 06547294cf5..1bbeeade26d 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -177,6 +177,10 @@ export const AuditAction = { PERMISSION_ACCESS_REQUEST_CLOSED: 'permission_access_request.closed', PERMISSION_ACCESS_REQUEST_SETTINGS_CHANGED: 'permission_access_request.settings_changed', + PROJECT_CREATED: 'project.created', + PROJECT_UPDATED: 'project.updated', + PROJECT_ARCHIVED: 'project.archived', + SANDBOX_CREATED: 'sandbox.created', SANDBOX_UPDATED: 'sandbox.updated', SANDBOX_DELETED: 'sandbox.deleted', @@ -218,9 +222,6 @@ export const AuditAction = { WORKFLOW_EXPORTED: 'workflow.exported', WORKSPACE_CREATED: 'workspace.created', - PROJECT_CREATED: 'project.created', - PROJECT_UPDATED: 'project.updated', - PROJECT_ARCHIVED: 'project.archived', WORKSPACE_UPDATED: 'workspace.updated', WORKSPACE_DELETED: 'workspace.deleted', WORKSPACE_DUPLICATED: 'workspace.duplicated', @@ -278,6 +279,7 @@ export const AuditResourceType = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', PERMISSION_ACCESS_REQUEST: 'permission_access_request', + PROJECT: 'project', SANDBOX: 'sandbox', SCHEDULE: 'schedule', SCIM_CONNECTION: 'scim_connection', @@ -290,7 +292,6 @@ export const AuditResourceType = { USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', - PROJECT: 'project', WORKSPACE: 'workspace', } as const diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 8a89a318ebc..2df4fc55132 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -149,6 +149,10 @@ const AuditAction = { PERMISSION_ACCESS_REQUEST_CANCELLED: 'permission_access_request.cancelled', PERMISSION_ACCESS_REQUEST_CLOSED: 'permission_access_request.closed', PERMISSION_ACCESS_REQUEST_SETTINGS_CHANGED: 'permission_access_request.settings_changed', + + PROJECT_CREATED: 'project.created', + PROJECT_UPDATED: 'project.updated', + PROJECT_ARCHIVED: 'project.archived', SANDBOX_CREATED: 'sandbox.created', SANDBOX_UPDATED: 'sandbox.updated', SANDBOX_DELETED: 'sandbox.deleted', @@ -238,6 +242,7 @@ const AuditResourceType = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', PERMISSION_ACCESS_REQUEST: 'permission_access_request', + PROJECT: 'project', SANDBOX: 'sandbox', SCHEDULE: 'schedule', SCIM_CONNECTION: 'scim_connection', From d32d4c93f78e774a6179486bf32130f5a596ddf5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 16:13:17 -0700 Subject: [PATCH 15/68] feat(mothership): keep each chat's reasoning effort, default to medium, restore Low (#8634) * feat(mothership): keep each chat's reasoning effort, default to medium, restore Low The simple picker offers Low / Medium / High / Extra High again, each sending exactly that effort. New chats and chats never changed run at medium instead of high. An effort the user picks is stored on the chat (copilot_chats.config) through a new PUT /api/mothership/chats/[chatId]/effort and at turn admission, and later turns of that chat keep it. The global last-used effort is no longer persisted. The Sim Chat block defaults to medium. * fix(mothership): keep the latest effort pick through refetches, failed saves and abandoned new chats * fix(mothership): leave a deduplicated send's chat on the pick its first attempt stored * fix(mothership): show a recovered chat's pick while its details load * fix(mothership): hand a withdrawn first send's effort pick back to the new-chat composer * fix(mothership): hand a withdrawn send's pick back only while its new-chat surface is open --- apps/sim/app/api/copilot/chat/queries.ts | 3 + .../mothership/chats/[chatId]/effort/route.ts | 23 ++++ .../api/mothership/chats/[chatId]/route.ts | 1 + .../components/composer/composer.test.tsx | 5 +- .../user-input/components/model-selector.tsx | 30 ++++- .../[workspaceId]/home/hooks/use-chat.ts | 40 ++++++- apps/sim/blocks/blocks/mothership.ts | 8 +- .../handlers/mothership/mothership-handler.ts | 3 +- apps/sim/hooks/queries/mothership-chats.ts | 44 +++++++ .../sim/lib/api/contracts/mothership-chats.ts | 22 ++++ .../chat/application/admit-turn.test.ts | 18 +++ .../mothership/chat/application/admit-turn.ts | 9 +- .../application/set-effort.integration.ts | 109 ++++++++++++++++++ .../mothership/chat/application/set-effort.ts | 59 ++++++++++ apps/sim/lib/mothership/chat/intent.ts | 21 +++- apps/sim/lib/mothership/chat/lifecycle.ts | 10 +- apps/sim/lib/mothership/chat/post.test.ts | 36 +++++- apps/sim/lib/mothership/chat/post.ts | 20 +++- apps/sim/lib/mothership/model-options.ts | 16 ++- .../stores/mothership-effort/store.test.ts | 31 ++--- apps/sim/stores/mothership-effort/store.ts | 83 +++++++------ 21 files changed, 519 insertions(+), 72 deletions(-) create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/effort/route.ts create mode 100644 apps/sim/lib/mothership/chat/application/set-effort.integration.ts create mode 100644 apps/sim/lib/mothership/chat/application/set-effort.ts diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index dde58162c57..565b14af083 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -12,6 +12,7 @@ import { readLiveTurnSnapshot, } from '@/lib/mothership/chat/live-turn-snapshot' import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -32,6 +33,7 @@ function transformChat(chat: { model: string | null messages: unknown config?: unknown + effort?: MothershipEffort | null conversationId?: string | null resources?: unknown createdAt: Date | null @@ -44,6 +46,7 @@ function transformChat(chat: { messages: Array.isArray(chat.messages) ? chat.messages : [], messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, config: chat.config || null, + effort: chat.effort ?? null, ...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}), ...('resources' in chat ? { resources: Array.isArray(chat.resources) ? chat.resources : [] } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/effort/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/effort/route.ts new file mode 100644 index 00000000000..dfa0ddc94b3 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/effort/route.ts @@ -0,0 +1,23 @@ +import { setMothershipChatEffortContract } from '@/lib/api/contracts/mothership-chats' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { setChatEffort } from '@/lib/mothership/chat/application/set-effort' + +/** Records the reasoning effort the owner picked for one of their chats. */ +export const PUT = defineInternalJsonRoute({ + contract: setMothershipChatEffortContract, + operation: setChatEffort.operation, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Personal chat settings updates have no separate rate bucket.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ chatId: params.chatId, effort: body.effort }), + useCase: setChatEffort, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index bd975a2dd99..6be0e4ada77 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -94,6 +94,7 @@ export const GET = withRouteHandler( messages: effectiveMessages, activeStreamId: liveStreamId, resources: Array.isArray(chat.resources) ? chat.resources : [], + effort: chat.effort, createdAt: chat.createdAt, updatedAt: chat.updatedAt, // Events stay out of the payload (the resume endpoint replays them), diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index 0348cbed5c9..aa1466b9499 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -102,10 +102,7 @@ let queryClient: QueryClient beforeEach(() => { mocks.advanced = false - useMothershipEffortStore.setState({ - effort: 'high', - modelSelection: { model: 'gpt-6-astra', fastMode: false }, - }) + useMothershipEffortStore.getState().reset() mocks.plan = false vi.clearAllMocks() mocks.workspaces = [ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx index 9ba8dfaf2e9..9616cbf980a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -11,12 +12,18 @@ import { Brain, Check, Sparkles } from '@sim/emcn/icons' import { MOTHERSHIP_MODEL_OPTIONS, MOTHERSHIP_SIMPLE_EFFORT_OPTIONS, + type MothershipEffort, mothershipEffortOptions, resolveMothershipModelSettings, } from '@/lib/mothership/model-options' +import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { FastModeToggle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/fast-mode-toggle' import { ModelSettingTrigger } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-setting-trigger' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { + useMothershipChatHistory, + useSetMothershipChatEffort, +} from '@/hooks/queries/mothership-chats' import { useMothershipEffortStore } from '@/stores/mothership-effort/store' /** Model, reasoning effort, and Fast mode for Build chat composers. */ @@ -25,15 +32,32 @@ export function ModelSelector() { const selection = useMothershipEffortStore((state) => state.modelSelection) const setModel = useMothershipEffortStore((state) => state.setModel) const setFastMode = useMothershipEffortStore((state) => state.setFastMode) - const storedEffort = useMothershipEffortStore((state) => state.effort) + const { chatId } = useChatSurface() + const { data: chatHistory } = useMothershipChatHistory(chatId) + const chatPick = useMothershipEffortStore((state) => + chatId ? state.chatEfforts[chatId] : undefined + ) + const newChatEffort = useMothershipEffortStore((state) => state.newChatEffort) + const setNewChatEffort = useMothershipEffortStore((state) => state.setNewChatEffort) + const { mutate: saveChatEffort } = useSetMothershipChatEffort(chatId) + const effortChoice = chatId ? (chatPick ?? chatHistory?.effort) : newChatEffort const { effort, modelSelection } = resolveMothershipModelSettings( - { effort: storedEffort, modelSelection: selection }, + { effort: effortChoice ?? undefined, modelSelection: selection }, advanced ) const options = advanced ? mothershipEffortOptions(modelSelection.model) : MOTHERSHIP_SIMPLE_EFFORT_OPTIONS - const setEffort = useMothershipEffortStore((state) => state.setEffort) + const setEffort = (choice: MothershipEffort) => { + if (chatId) saveChatEffort(choice) + else setNewChatEffort(choice) + } + + useEffect(() => { + if (chatId) return + return () => useMothershipEffortStore.getState().setNewChatEffort(null) + }, [chatId]) + const effortLabel = options.find((option) => option.value === effort)?.label ?? effort const modelLabel = MOTHERSHIP_MODEL_OPTIONS.find((option) => option.value === modelSelection.model)?.label ?? diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index b8b6d1a9f76..0a85a441003 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -926,6 +926,14 @@ export function useChat( new Set()) const streamReaderRef = useRef | null>(null) const chatIdRef = useRef(initialChatId) + /** Cleared on unmount, so a late rollback cannot hand a pick to a surface the user left. */ + const surfaceMountedRef = useRef(true) + useEffect(() => { + surfaceMountedRef.current = true + return () => { + surfaceMountedRef.current = false + } + }, []) const tableViewContextsRef = useRef({ scopeId: desktopScopeId, views: new Map(), @@ -3396,6 +3404,18 @@ export function useChat( let requestChatId = queuedSendHandoff?.chatId ?? selectedChatIdRef.current ?? chatIdRef.current + // Read before the composer can unmount. Sent only when picked; otherwise the server + // uses the chat's stored pick or the default. + const effortStore = useMothershipEffortStore.getState() + const effortChoice = + options?.requestMode === 'assistant' + ? undefined + : requestChatId + ? (effortStore.chatEfforts[requestChatId] ?? + queryClient.getQueryData( + mothershipChatKeys.detail(requestChatId) + )?.effort) + : effortStore.newChatEffort const writeQueuedSendHandoff = (chatId?: string) => { if (!queuedSendHandoff) return if (!chatId && !queuedSendHandoff.supersededStreamId) return @@ -3530,6 +3550,16 @@ export function useChat( } const rollbackOptimisticSend = () => { + // A withdrawn first send hands its pick back to the new-chat composer for the retry, + // only while that surface is still open on the new chat. + if ( + !requestChatId && + effortChoice && + surfaceMountedRef.current && + !chatIdRef.current && + !selectedChatIdRef.current + ) + useMothershipEffortStore.getState().setNewChatEffort(effortChoice) if (requestChatId) { upsertChatHistory(requestChatId, (current) => ({ ...current, @@ -3707,10 +3737,11 @@ export function useChat( userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, ...(options?.requestMode !== 'assistant' ? { - ...resolveMothershipModelSettings( + modelSelection: resolveMothershipModelSettings( useMothershipEffortStore.getState(), modelSelectorEnabled - ), + ).modelSelection, + ...(effortChoice ? { effort: effortChoice } : {}), } : {}), }), @@ -3727,6 +3758,8 @@ export function useChat( return consumedByTranscript } if (admittedChatId && !requestChatId) { + if (effortChoice) + useMothershipEffortStore.getState().adoptNewChatEffort(admittedChatId, effortChoice) requestChatId = admittedChatId streamTargetChatId = admittedChatId adoptResolvedChatId(admittedChatId, { replaceHomeHistory: true, invalidateList: true }) @@ -3775,6 +3808,9 @@ export function useChat( const conflictChatId = typeof errorData.chatId === 'string' ? errorData.chatId : undefined if (conflictChatId && !streamTargetChatId) { + // The retry carries the same pick the first attempt stored on that chat. + if (effortChoice) + useMothershipEffortStore.getState().adoptNewChatEffort(conflictChatId, effortChoice) adoptResolvedChatId(conflictChatId, { replaceHomeHistory: true, invalidateList: true, diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 7f745e07fe1..b15d1a93c84 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,5 +1,9 @@ import { Blimp } from '@sim/emcn/icons' -import { MOTHERSHIP_EFFORT_OPTIONS, MOTHERSHIP_MODEL_OPTIONS } from '@/lib/mothership/model-options' +import { + DEFAULT_MOTHERSHIP_EFFORT, + MOTHERSHIP_EFFORT_OPTIONS, + MOTHERSHIP_MODEL_OPTIONS, +} from '@/lib/mothership/model-options' import type { BlockConfig } from '@/blocks/types' export const MothershipBlock: BlockConfig = { @@ -45,7 +49,7 @@ export const MothershipBlock: BlockConfig = { title: 'Reasoning Effort', type: 'dropdown', options: MOTHERSHIP_EFFORT_OPTIONS.map(({ value, label }) => ({ id: value, label })), - value: () => 'high', + value: () => DEFAULT_MOTHERSHIP_EFFORT, }, { id: 'fastMode', diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index ec27a4848a0..2e0a5c8ecac 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -29,6 +29,7 @@ import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from import { resolveMcpToolBinding } from '@/lib/mcp/tool-binding' import { resolveMothershipConversation } from '@/lib/mothership/conversation-id' import { ChatPayloadSchema, ModelSelectionSchema } from '@/lib/mothership/generated/protocol' +import { DEFAULT_MOTHERSHIP_EFFORT } from '@/lib/mothership/model-options' import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import { areModelSafeWorkspaceFileKeys, @@ -971,7 +972,7 @@ export class MothershipBlockHandler implements BlockHandler { model: inputs.model ?? 'gpt-6-astra', fastMode: inputs.model === 'claude-opus-5' ? false : (inputs.fastMode ?? false), }) - const effort = ChatPayloadSchema.shape.effort.parse(inputs.effort ?? 'high') + const effort = ChatPayloadSchema.shape.effort.parse(inputs.effort ?? DEFAULT_MOTHERSHIP_EFFORT) const body: Record = { messages, useConversationHistory: true, diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 48ea0783916..314489d4b5c 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -20,18 +20,22 @@ import { type MothershipChatScope, markMothershipChatReadContract, restoreMothershipChatContract, + type SetMothershipChatEffortBody, + setMothershipChatEffortContract, updateMothershipChatContract, } from '@/lib/api/contracts/mothership-chats' import { mothershipResourceSchema } from '@/lib/api/contracts/mothership-resources' import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope' import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { type FilePreviewSession, isFilePreviewSession, } from '@/lib/mothership/request/session/file-preview-session-contract' import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/mothership/request/session/types' import type { MothershipResource } from '@/lib/mothership/resources/types' +import { useMothershipEffortStore } from '@/stores/mothership-effort/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' export interface MothershipChatMetadata { @@ -53,6 +57,8 @@ export interface MothershipChatHistory { messages: PersistedMessage[] activeStreamId: string | null resources: MothershipResource[] + /** The effort the user picked for this chat; null or absent while it follows the default. */ + effort?: MothershipEffort | null streamSnapshot?: { events: StreamBatchEvent[] previewSessions: FilePreviewSession[] @@ -200,6 +206,7 @@ function parseChatHistory(value: unknown): MothershipChatHistory { messages: normalizeMessages(chat.messages), activeStreamId: chat.activeStreamId, resources: parseResources(chat.resources, `${chatContext}.resources`), + effort: getMothershipChatResponseSchema.shape.chat.shape.effort.parse(chat.effort ?? null), streamSnapshot: parseStrictStreamSnapshot(chat.streamSnapshot, `${chatContext}.streamSnapshot`), } } @@ -581,6 +588,43 @@ export function useSetMothershipChatPinned(owner?: MothershipChatOwner) { }) } +async function setChatEffort({ + chatId, + effort, +}: SetMothershipChatEffortBody & { chatId: string }): Promise { + await requestJson(setMothershipChatEffortContract, { + params: { chatId }, + body: { effort }, + }) +} + +/** + * Records the effort the user picked for a chat. The pick shows and sends at once from the + * session's pick map; saves for one chat run one at a time so the last pick is the one stored. + */ +export function useSetMothershipChatEffort(chatId: string | undefined) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (effort: MothershipEffort) => { + if (!chatId) throw new Error('A chat effort needs a chat') + return setChatEffort({ chatId, effort }) + }, + scope: { id: `mothership-chat-effort:${chatId ?? ''}` }, + onMutate: (effort) => { + if (chatId) useMothershipEffortStore.getState().setChatEffort(chatId, effort) + }, + onError: (_error, effort) => { + if (chatId) useMothershipEffortStore.getState().dropChatEffort(chatId, effort) + }, + onSuccess: (_data, effort) => { + queryClient.setQueryData( + mothershipChatKeys.detail(chatId), + (current) => current && { ...current, effort } + ) + }, + }) +} + async function forkChat(params: { chatId: string upToMessageId: string diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 0370e50d74a..11b7670a2cd 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -39,6 +39,14 @@ export const mothershipChatParamsSchema = z.object({ chatId: z.string().min(1), }) +/** The effort the user picked for a chat; null while the chat follows the default. */ +const mothershipChatEffortChoiceSchema = ChatPayloadSchema.shape.effort.unwrap().nullable() + +const setMothershipChatEffortBodySchema = z.object({ + effort: ChatPayloadSchema.shape.effort.unwrap(), +}) +export type SetMothershipChatEffortBody = z.input + export const updateMothershipChatBodySchema = z .object({ title: z.string().trim().min(1).max(200).optional(), @@ -309,6 +317,19 @@ export const updateMothershipChatContract = defineRouteContract({ }, }) +export const setMothershipChatEffortContract = defineRouteContract({ + method: 'PUT', + path: '/api/mothership/chats/[chatId]/effort', + params: mothershipChatParamsSchema, + body: setMothershipChatEffortBodySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + }), + }, +}) + export const deleteMothershipChatContract = defineRouteContract({ method: 'DELETE', path: '/api/mothership/chats/[chatId]', @@ -399,6 +420,7 @@ export const getMothershipChatResponseSchema = z.object({ messages: z.array(z.unknown()), activeStreamId: z.string().nullable(), resources: z.array(z.unknown()), + effort: mothershipChatEffortChoiceSchema, createdAt: z.union([z.string(), z.date()]).nullable().optional(), updatedAt: z.union([z.string(), z.date()]).nullable().optional(), streamSnapshot: mothershipChatStreamSnapshotSchema.optional(), diff --git a/apps/sim/lib/mothership/chat/application/admit-turn.test.ts b/apps/sim/lib/mothership/chat/application/admit-turn.test.ts index 9bdb2ddbd64..f2f8a45c923 100644 --- a/apps/sim/lib/mothership/chat/application/admit-turn.test.ts +++ b/apps/sim/lib/mothership/chat/application/admit-turn.test.ts @@ -151,6 +151,24 @@ describe('organization turn admission through current private-chat authorization ) } ) + it.each([ + [undefined, false], + ['low', true], + ] as const)( + 'records the send effort %s as the chat choice in the admission write: %s', + async (effortChoice, recorded) => { + queueTableRows(copilotChats, [chat]) + queueTableRows(member, [{ role: 'member' }]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ model: null }]) + .mockResolvedValueOnce([{ id: 'run-1' }]) + .mockResolvedValueOnce([{ key: 'claim' }]) + await admitChatTurn.execute({ principal, input: { ...input(), effortChoice } }) + const config = dbChainMockFns.set.mock.calls[0][0].config.toSQL() + expect(config.sql.includes("jsonb_build_object('effort'")).toBe(recorded) + expect(config.params.includes('low')).toBe(recorded) + } + ) it.each(['agent', 'plan'] as const)( 'denies switching to %s without current workspace-create permission before any mutation', async (mode) => { diff --git a/apps/sim/lib/mothership/chat/application/admit-turn.ts b/apps/sim/lib/mothership/chat/application/admit-turn.ts index 27271d1dc7c..e1b4a4ce516 100644 --- a/apps/sim/lib/mothership/chat/application/admit-turn.ts +++ b/apps/sim/lib/mothership/chat/application/admit-turn.ts @@ -11,6 +11,7 @@ import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/avail import { insertRunSegment, withRunAdmissionLock } from '@/lib/mothership/async-runs/repository' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { withChatEffortChoice } from '@/lib/mothership/chat/intent' import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats' import { @@ -18,6 +19,7 @@ import { type UserMessageParams, } from '@/lib/mothership/chat/persisted-message' import { publishChatStatusChanged } from '@/lib/mothership/chat-status' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { StreamRecoveryConfigSchema } from '@/lib/mothership/request/lifecycle/recovery-config' import { assertChatStreamLease, @@ -34,6 +36,8 @@ interface AdmitTurnInput { lease: ChatStreamLease sendClaim: { normalizedKey: string; claimToken: string } notifyWorkspaceStatus: boolean + /** The effort this send picked, kept as the chat's choice for later turns. */ + effortChoice?: MothershipEffort } /** The accepted message, its start intent and retry destination commit together. */ @@ -79,13 +83,16 @@ export const admitChatTurn = defineAuthorizedChatUseCase({ else await requireOrganizationSearchAvailable(organizationId) } await assertChatStreamLease(input.lease) + const turnConfig = sql`COALESCE(${copilotChats.config}, '{}'::jsonb) || jsonb_build_object('conversationMode', ${request.mode ?? 'agent'}::text)` return withRunAdmissionLock(userId, request.messageId, async (tx) => { const [chat] = await tx .update(copilotChats) .set({ conversationId: request.messageId, updatedAt: new Date(), - config: sql`COALESCE(${copilotChats.config}, '{}'::jsonb) || jsonb_build_object('conversationMode', ${request.mode ?? 'agent'}::text)`, + config: input.effortChoice + ? withChatEffortChoice(turnConfig, input.effortChoice) + : turnConfig, }) .where( and( diff --git a/apps/sim/lib/mothership/chat/application/set-effort.integration.ts b/apps/sim/lib/mothership/chat/application/set-effort.integration.ts new file mode 100644 index 00000000000..29a3b33e596 --- /dev/null +++ b/apps/sim/lib/mothership/chat/application/set-effort.integration.ts @@ -0,0 +1,109 @@ +/** Exercises a chat's explicit effort choice against real PostgreSQL rows. */ +import { db } from '@sim/db' +import { copilotChats, permissions, user, workspace } from '@sim/db/schema' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { setChatEffort } from '@/lib/mothership/chat/application/set-effort' +import { getAccessibleCopilotChatWithMessages } from '@/lib/mothership/chat/lifecycle' +import { resolveMothershipModelSettings } from '@/lib/mothership/model-options' + +const ownerId = generateId() +const outsiderId = generateId() +const workspaceId = generateId() +const owner = createSessionPrincipal({ userId: ownerId, sessionId: generateId() }) +const outsider = createSessionPrincipal({ userId: outsiderId, sessionId: generateId() }) + +async function createChat(config: Record | null): Promise { + const [chat] = await db + .insert(copilotChats) + .values({ userId: ownerId, workspaceId, type: 'mothership', config }) + .returning({ id: copilotChats.id }) + return chat.id +} + +async function loadEffort(chatId: string) { + const chat = await getAccessibleCopilotChatWithMessages(chatId, ownerId) + return chat?.effort +} + +/** What the next turn of this chat runs at when the send names no effort. */ +async function nextTurnEffort(chatId: string) { + return resolveMothershipModelSettings({ effort: (await loadEffort(chatId)) ?? undefined }, false) + .effort +} + +beforeAll(async () => { + const now = new Date() + await db.insert(user).values( + [ownerId, outsiderId].map((id) => ({ + id, + name: 'Chat effort fixture', + email: `${id}@chat-effort.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(workspace).values({ + id: workspaceId, + name: 'Chat effort fixture', + ownerId, + billedAccountUserId: ownerId, + }) + await db.insert(permissions).values( + [ownerId, outsiderId].map((userId) => ({ + id: generateId(), + userId, + entityType: 'workspace' as const, + entityId: workspaceId, + permissionType: 'admin' as const, + })) + ) +}) + +afterAll(async () => { + await db.delete(copilotChats).where(eq(copilotChats.workspaceId, workspaceId)) + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(inArray(user.id, [ownerId, outsiderId])) + await db.$client.end() +}) + +describe('a chat keeps the effort its owner picked', () => { + it('runs a chat with no pick at the default and a picked chat at its pick', async () => { + const untouched = await createChat(null) + const picked = await createChat({ conversationMode: 'plan' }) + + await setChatEffort.execute({ principal: owner, input: { chatId: picked, effort: 'low' } }) + + expect(await loadEffort(untouched)).toBeNull() + expect(await nextTurnEffort(untouched)).toBe('medium') + expect(await nextTurnEffort(picked)).toBe('low') + const [row] = await db + .select({ config: copilotChats.config }) + .from(copilotChats) + .where(eq(copilotChats.id, picked)) + expect(row.config).toEqual({ conversationMode: 'plan', effort: 'low' }) + }) + + it('replaces an earlier pick with the latest one', async () => { + const chatId = await createChat({ effort: 'xhigh' }) + await setChatEffort.execute({ principal: owner, input: { chatId, effort: 'high' } }) + expect(await loadEffort(chatId)).toBe('high') + }) + + it('reads a stored value outside the effort range as no pick', async () => { + const chatId = await createChat({ effort: 'turbo' }) + expect(await loadEffort(chatId)).toBeNull() + expect(await nextTurnEffort(chatId)).toBe('medium') + }) + + it("refuses to change another user's chat in a shared workspace", async () => { + const chatId = await createChat(null) + await expect( + setChatEffort.execute({ principal: outsider, input: { chatId, effort: 'max' } }) + ).rejects.toThrow('Chat not found') + expect(await loadEffort(chatId)).toBeNull() + }) +}) diff --git a/apps/sim/lib/mothership/chat/application/set-effort.ts b/apps/sim/lib/mothership/chat/application/set-effort.ts new file mode 100644 index 00000000000..468e54620e8 --- /dev/null +++ b/apps/sim/lib/mothership/chat/application/set-effort.ts @@ -0,0 +1,59 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { and, eq, isNull, sql } from 'drizzle-orm' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' +import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { withChatEffortChoice } from '@/lib/mothership/chat/intent' +import type { MothershipEffort } from '@/lib/mothership/model-options' + +interface SetChatEffortInput { + chatId: string + effort: MothershipEffort +} + +/** Records the effort the owner picked for a chat, so its later turns keep it. */ +export const setChatEffort = defineAuthorizedChatUseCase({ + // permission-group-exempt: a chat's reasoning effort is owned chat metadata and starts no turn + operation: defineWorkspaceOperation({ + id: 'mothership.chats.set_effort', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + /** permission-group-exempt: a chat's reasoning effort is owned chat metadata and starts no turn. */ + organizationOperation: defineOrganizationOperation({ + id: 'mothership.chats.set_effort', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session'], + }), + resolveContext({ principal, input }: { principal: SessionPrincipal; input: SetChatEffortInput }) { + return resolveOwnedChatContext(principal, input.chatId) + }, + authorizationOptions: {}, + async execute({ context, input }) { + const [chat] = await db + .update(copilotChats) + .set({ + config: withChatEffortChoice( + sql`COALESCE(${copilotChats.config}, '{}'::jsonb)`, + input.effort + ), + }) + .where( + and( + eq(copilotChats.id, context.chatId), + eq(copilotChats.userId, context.userId), + isNull(copilotChats.deletedAt) + ) + ) + .returning({ id: copilotChats.id }) + if (!chat) throw new OrchestrationError('not_found', 'Chat not found') + return {} + }, +}) diff --git a/apps/sim/lib/mothership/chat/intent.ts b/apps/sim/lib/mothership/chat/intent.ts index 2813a7e0e78..0652feb8578 100644 --- a/apps/sim/lib/mothership/chat/intent.ts +++ b/apps/sim/lib/mothership/chat/intent.ts @@ -1,5 +1,7 @@ import { copilotChats } from '@sim/db/schema' -import { sql } from 'drizzle-orm' +import { type SQL, sql } from 'drizzle-orm' +import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol' +import type { MothershipEffort } from '@/lib/mothership/model-options' export type ConversationMode = 'agent' | 'assistant' | 'plan' @@ -9,3 +11,20 @@ export const conversationModeSelection = sql`CASE WHEN ${copilotChats.organizationId} IS NULL THEN 'agent' WHEN ${copilotChats.config}->>'conversationMode' = 'agent' THEN 'agent' ELSE 'assistant' END` + +const MOTHERSHIP_EFFORTS = ChatPayloadSchema.shape.effort.unwrap().options + +/** + * The effort the user explicitly picked for this chat, or null while it follows the default. + * A stored value outside the protocol's effort range reads as no choice. + */ +export const chatEffortSelection = sql`CASE + WHEN ${copilotChats.config}->>'effort' IN (${sql.join( + MOTHERSHIP_EFFORTS.map((effort) => sql`${effort}`), + sql`, ` + )}) THEN ${copilotChats.config}->>'effort' END` + +/** Merges an explicit effort choice into a chat config expression, keeping its other keys. */ +export function withChatEffortChoice(config: SQL, effort: MothershipEffort): SQL { + return sql`${config} || jsonb_build_object('effort', ${effort}::text)` +} diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index d58546c5b10..7d40c26c769 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -8,7 +8,11 @@ import { } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { type ConversationMode, conversationModeSelection } from '@/lib/mothership/chat/intent' +import { + type ConversationMode, + chatEffortSelection, + conversationModeSelection, +} from '@/lib/mothership/chat/intent' import { authorizeOrganizationChat, authorizeOrganizationChatCancellation, @@ -17,6 +21,7 @@ import { type PersistedMessage, stripToolResultOutput, } from '@/lib/mothership/chat/persisted-message' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -59,6 +64,7 @@ const copilotChatDetailColumns = { title: copilotChats.title, conversationId: copilotChats.conversationId, resources: copilotChats.resources, + effort: chatEffortSelection, createdAt: copilotChats.createdAt, updatedAt: copilotChats.updatedAt, } as const @@ -163,7 +169,7 @@ export type CopilotChatDetail = Pick< | 'resources' | 'createdAt' | 'updatedAt' -> & { mode: ConversationMode } +> & { mode: ConversationMode; effort: MothershipEffort | null } export type CopilotChatDetailRow = CopilotChatDetail & { /** Transcript assembled from `copilot_messages` (no longer a chat-row column). */ diff --git a/apps/sim/lib/mothership/chat/post.test.ts b/apps/sim/lib/mothership/chat/post.test.ts index 6e28a4e67ae..7ef76f2eb9f 100644 --- a/apps/sim/lib/mothership/chat/post.test.ts +++ b/apps/sim/lib/mothership/chat/post.test.ts @@ -813,7 +813,7 @@ describe('handleUnifiedChatPost', () => { ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'xhigh'], - ['low', 'medium'], + ['low', 'low'], ['none', 'medium'], ])( 'enforces the default model and effort range on submitted %s effort', @@ -840,6 +840,40 @@ describe('handleUnifiedChatPost', () => { } ) + it.each([ + { stored: null, sent: undefined, runs: 'medium' }, + { stored: 'high', sent: undefined, runs: 'high' }, + { stored: 'low', sent: undefined, runs: 'low' }, + { stored: 'high', sent: 'low', runs: 'low' }, + { stored: 'high', sent: 'max', runs: 'xhigh' }, + ] as const)( + 'runs a chat whose stored effort choice is $stored at $runs when the send names $sent', + async ({ stored, sent, runs }) => { + flags.models.mockResolvedValue(false) + resolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1', effort: stored }, + isNew: false, + }) + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/mothership/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Continue', + workspaceId: 'ws-1', + chatId: 'chat-1', + ...(sent ? { effort: sent } : {}), + }), + }) + ) + expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ effort: runs }) + ) + expect(admitTurn.mock.calls[0][0].input.effortChoice).toBe(sent && runs) + } + ) + it('routes workflow-attached chat requests through the copilot backend path', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index e980d9be025..76cc073961a 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -983,12 +983,15 @@ export async function handleUnifiedChatPost(req: NextRequest) { const authenticatedUserEmail = session.user.email const body = ChatMessageSchema.parse(await req.json()) + // Admission records a send's own effort as the chat's explicit choice. + const effortChoice = body.mode === 'assistant' ? undefined : body.effort + let modelSelectorEnabled = false if (body.mode !== 'assistant') { - const [modelSelectorEnabled, planEnabled] = await Promise.all([ + const [selectorEnabled, planEnabled] = await Promise.all([ isMothershipModelSelectorEnabled(), body.mode === 'plan' ? isPlanModeEnabled() : false, ]) - Object.assign(body, resolveMothershipModelSettings(body, modelSelectorEnabled)) + modelSelectorEnabled = selectorEnabled if (body.mode === 'plan' && !planEnabled) return createBadRequestResponse('Plan mode is disabled') } @@ -1178,6 +1181,17 @@ export async function handleUnifiedChatPost(req: NextRequest) { return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) } } + if (body.mode !== 'assistant') + Object.assign( + body, + resolveMothershipModelSettings( + { + effort: effortChoice ?? currentChat?.effort ?? undefined, + modelSelection: body.modelSelection, + }, + modelSelectorEnabled + ) + ) let pendingStreamWaitMs = 0 if (actualChatId) { @@ -1466,6 +1480,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { requestMode: body.mode, }, notifyWorkspaceStatus: branch.notifyChatStatus, + // The effort this turn actually runs at, so the stored pick is always one it can use. + effortChoice: effortChoice && body.effort, }, }) // Admission committed. A failure to attach this HTTP sink must leave the turn recoverable. diff --git a/apps/sim/lib/mothership/model-options.ts b/apps/sim/lib/mothership/model-options.ts index c3f28dc3607..cc110f0a871 100644 --- a/apps/sim/lib/mothership/model-options.ts +++ b/apps/sim/lib/mothership/model-options.ts @@ -17,7 +17,15 @@ export const MOTHERSHIP_MODEL_OPTIONS = [ { value: 'claude-opus-5-5', label: 'Opus 5.5' }, ] satisfies Array<{ value: ModelSelection['model']; label: string }> -const SIMPLE_EFFORT_VALUES: ReadonlySet = new Set(['medium', 'high', 'xhigh']) +/** The effort a chat or Sim Chat block runs at until the user picks one. */ +export const DEFAULT_MOTHERSHIP_EFFORT: MothershipEffort = 'medium' + +const SIMPLE_EFFORT_VALUES: ReadonlySet = new Set([ + 'low', + 'medium', + 'high', + 'xhigh', +]) /** The efforts the simple picker offers, labeled with the effort each one sends. */ export const MOTHERSHIP_SIMPLE_EFFORT_OPTIONS = MOTHERSHIP_EFFORT_OPTIONS.filter((option) => @@ -35,15 +43,15 @@ export function resolveMothershipModelSettings( settings: { effort?: MothershipEffort; modelSelection?: ModelSelection }, advanced: boolean ): { effort: MothershipEffort; modelSelection: ModelSelection } { - let effort = settings.effort ?? 'high' + let effort = settings.effort ?? DEFAULT_MOTHERSHIP_EFFORT if (!advanced) { - if (effort === 'none' || effort === 'low') effort = 'medium' + if (effort === 'none') effort = DEFAULT_MOTHERSHIP_EFFORT if (effort === 'max') effort = 'xhigh' return { effort, modelSelection: { model: 'gpt-6-astra', fastMode: false } } } const stored = settings.modelSelection ?? { model: 'gpt-6-astra', fastMode: false } const model = stored.model === 'claude-opus-5' ? 'claude-opus-5-5' : stored.model - if (effort === 'none' && model !== 'gpt-6-sol') effort = 'medium' + if (effort === 'none' && model !== 'gpt-6-sol') effort = DEFAULT_MOTHERSHIP_EFFORT return { effort, modelSelection: { model, fastMode: model === 'claude-opus-5-5' ? false : stored.fastMode }, diff --git a/apps/sim/stores/mothership-effort/store.test.ts b/apps/sim/stores/mothership-effort/store.test.ts index 43ec1ea49e1..5f8d3108995 100644 --- a/apps/sim/stores/mothership-effort/store.test.ts +++ b/apps/sim/stores/mothership-effort/store.test.ts @@ -4,14 +4,11 @@ import { useMothershipEffortStore } from '@/stores/mothership-effort/store' beforeEach(() => { localStorage.clear() - useMothershipEffortStore.setState({ - effort: 'high', - modelSelection: { model: 'gpt-6-astra', fastMode: false }, - }) + useMothershipEffortStore.getState().reset() }) describe('Build reasoning preferences', () => { - it('updates a saved Opus selection to Opus 5.5 while preserving effort', async () => { + it('updates a saved Opus selection to Opus 5.5 and drops a saved global effort', async () => { localStorage.setItem( 'mothership-effort', JSON.stringify({ @@ -21,29 +18,35 @@ describe('Build reasoning preferences', () => { ) await useMothershipEffortStore.persist.rehydrate() expect(useMothershipEffortStore.getState()).toMatchObject({ - effort: 'xhigh', + newChatEffort: null, modelSelection: { model: 'claude-opus-5-5', fastMode: false }, }) useMothershipEffortStore.getState().setFastMode(true) expect(JSON.parse(localStorage.getItem('mothership-effort')!).state).toEqual({ - effort: 'xhigh', modelSelection: { model: 'claude-opus-5-5', fastMode: false }, }) }) - it('restores effort and Fast mode together without changing the Build model', async () => { - useMothershipEffortStore.getState().setEffort('max') + it('restores Fast mode but not a new chat effort pick across reloads', async () => { + useMothershipEffortStore.getState().setNewChatEffort('low') useMothershipEffortStore.getState().setFastMode(true) const saved = localStorage.getItem('mothership-effort')! - useMothershipEffortStore.setState({ - effort: 'high', - modelSelection: { model: 'gpt-6-astra', fastMode: false }, - }) + useMothershipEffortStore.getState().reset() localStorage.setItem('mothership-effort', saved) await useMothershipEffortStore.persist.rehydrate() expect(useMothershipEffortStore.getState()).toMatchObject({ - effort: 'max', + newChatEffort: null, modelSelection: { model: 'gpt-6-astra', fastMode: true }, }) }) + + it('keeps a newer chat pick when an older pick fails to save', () => { + const store = useMothershipEffortStore.getState() + store.setChatEffort('chat-1', 'low') + store.setChatEffort('chat-1', 'high') + store.dropChatEffort('chat-1', 'low') + expect(useMothershipEffortStore.getState().chatEfforts).toEqual({ 'chat-1': 'high' }) + store.dropChatEffort('chat-1', 'high') + expect(useMothershipEffortStore.getState().chatEfforts).toEqual({}) + }) }) diff --git a/apps/sim/stores/mothership-effort/store.ts b/apps/sim/stores/mothership-effort/store.ts index 7fdc6f94390..76849d6120a 100644 --- a/apps/sim/stores/mothership-effort/store.ts +++ b/apps/sim/stores/mothership-effort/store.ts @@ -1,9 +1,8 @@ -import { toRecord } from '@sim/utils/object' +import { omit, toRecord } from '@sim/utils/object' import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { type ModelSelection, ModelSelectionSchema } from '@/lib/mothership/generated/protocol' import { - MOTHERSHIP_EFFORT_OPTIONS, type MothershipEffort, resolveMothershipModelSettings, } from '@/lib/mothership/model-options' @@ -12,15 +11,39 @@ interface MothershipEffortState { modelSelection: ModelSelection setModel: (model: ModelSelection['model']) => void setFastMode: (fastMode: boolean) => void - effort: MothershipEffort - setEffort: (effort: MothershipEffort) => void + /** + * The effort picked in a composer whose chat does not exist yet. Its first send records + * it on the new chat; leaving that composer unsent drops it. + */ + newChatEffort: MothershipEffort | null + setNewChatEffort: (effort: MothershipEffort | null) => void + /** + * Picks made in existing chats this session, by chat id. They win over the chat's loaded + * value, so a detail refetch or a save still in flight never shows or sends an older one. + */ + chatEfforts: Record + setChatEffort: (chatId: string, effort: MothershipEffort) => void + /** Drops a pick whose save failed, unless a newer pick replaced it. */ + dropChatEffort: (chatId: string, effort: MothershipEffort) => void + /** Moves the new-chat pick onto the chat its first send created. */ + adoptNewChatEffort: (chatId: string, effort: MothershipEffort) => void reset: () => void } -const initialState = { - effort: 'high', +const initialState: Pick< + MothershipEffortState, + 'modelSelection' | 'newChatEffort' | 'chatEfforts' +> = { modelSelection: { model: 'gpt-6-astra', fastMode: false }, -} satisfies Pick + newChatEffort: null, + chatEfforts: {}, +} + +function withModelSelection( + modelSelection: ModelSelection +): Pick { + return { modelSelection: resolveMothershipModelSettings({ modelSelection }, true).modelSelection } +} export const useMothershipEffortStore = create()( devtools( @@ -28,42 +51,32 @@ export const useMothershipEffortStore = create()( (set) => ({ ...initialState, setFastMode: (fastMode) => - set((state) => - resolveMothershipModelSettings( - { ...state, modelSelection: { ...state.modelSelection, fastMode } }, - true - ) - ), + set((state) => withModelSelection({ ...state.modelSelection, fastMode })), setModel: (model) => - set((state) => - resolveMothershipModelSettings( - { ...state, modelSelection: { model, fastMode: state.modelSelection.fastMode } }, - true - ) - ), - setEffort: (effort) => set({ effort }), + set((state) => withModelSelection({ model, fastMode: state.modelSelection.fastMode })), + setNewChatEffort: (newChatEffort) => set({ newChatEffort }), + setChatEffort: (chatId, effort) => + set((state) => ({ chatEfforts: { ...state.chatEfforts, [chatId]: effort } })), + dropChatEffort: (chatId, effort) => + set((state) => { + if (state.chatEfforts[chatId] !== effort) return state + return { chatEfforts: omit(state.chatEfforts, [chatId]) } + }), + adoptNewChatEffort: (chatId, effort) => + set((state) => ({ + newChatEffort: null, + chatEfforts: { ...state.chatEfforts, [chatId]: effort }, + })), reset: () => set(initialState), }), { name: 'mothership-effort', - partialize: ({ effort, modelSelection }) => ({ effort, modelSelection }), + partialize: ({ modelSelection }) => ({ modelSelection }), merge: (persistedState, currentState) => { - const persisted = toRecord(persistedState) - const selection = ModelSelectionSchema.safeParse(persisted.modelSelection) - const effort = - persisted.effort === 'none' - ? 'none' - : (MOTHERSHIP_EFFORT_OPTIONS.find((option) => option.value === persisted.effort) - ?.value ?? currentState.effort) + const selection = ModelSelectionSchema.safeParse(toRecord(persistedState).modelSelection) return { ...currentState, - ...resolveMothershipModelSettings( - { - effort, - modelSelection: selection.success ? selection.data : currentState.modelSelection, - }, - true - ), + ...withModelSelection(selection.success ? selection.data : currentState.modelSelection), } }, } From bd06886286c50e34f962f53bd8609718f504b045 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 16:36:34 -0700 Subject: [PATCH 16/68] feat(library): Best AI Coding Agents for Refactoring and Debugging: 5 Tools Compared (#8638) Co-authored-by: Sim Pi Agent --- .../index.mdx | 322 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 29218 bytes 2 files changed, 322 insertions(+) create mode 100644 apps/sim/content/library/best-ai-coding-agents-refactoring-debugging/index.mdx create mode 100644 apps/sim/public/library/best-ai-coding-agents-refactoring-debugging/cover.jpg diff --git a/apps/sim/content/library/best-ai-coding-agents-refactoring-debugging/index.mdx b/apps/sim/content/library/best-ai-coding-agents-refactoring-debugging/index.mdx new file mode 100644 index 00000000000..f26bbb051d4 --- /dev/null +++ b/apps/sim/content/library/best-ai-coding-agents-refactoring-debugging/index.mdx @@ -0,0 +1,322 @@ +--- +slug: best-ai-coding-agents-refactoring-debugging +title: 'Best AI Coding Agents for Refactoring and Debugging: 5 Tools Compared' +description: 'Compare five AI coding agents for multi-file refactoring and complex debugging, including Claude Code, Cursor, GitHub Copilot, OpenAI Codex, and Gemini CLI.' +date: 2026-10-05 +updated: 2026-10-05 +authors: + - andrew +readingTime: 13 +tags: [AI Agents, Developer Tools, Coding Agents, Sim] +ogImage: /library/best-ai-coding-agents-refactoring-debugging/cover.jpg +draft: false +faq: + - q: "Can agentic coding tools handle multi file refactoring?" + a: "AI coding agents can handle multi-file refactoring when they can trace dependencies, coordinate edits, run tests, and present the complete change for human review." + - q: "What are the best AI coding agents for debugging complex codebases?" + a: "Claude Code is the best overall choice in this comparison for debugging complex codebases, with Cursor strongest for editor-centered investigation and GitHub Copilot strongest for GitHub-native teams." + - q: "What should engineering teams look for when evaluating agentic coding tools?" + a: "Engineering teams should evaluate agentic coding tools on task accuracy, repository context, permissions, observability, verification, rollback, human review time, and performance on representative internal tasks." + - q: "Which AI coding agent is best for refactoring?" + a: "Cursor is the best AI coding agent in this comparison for interactive multi-file refactoring, while Claude Code is the stronger default when the refactor requires extensive terminal-based investigation and verification." + - q: "Which AI coding agent is best for debugging?" + a: "Claude Code is the best AI coding agent in this comparison for debugging because it supports an iterative loop across repository inspection, shell commands, tests, and runtime evidence." + - q: "Can AI coding agents debug production issues?" + a: "AI coding agents can assist with production debugging when they receive sanitized evidence and read-only access, but engineering teams should keep production writes, secret access, merges, and deployments behind separate approval controls." + - q: "Can AI coding agents work with large monorepos?" + a: "AI coding agents can work with large monorepos when they retrieve context selectively, respect package boundaries, and run scoped checks, but every team should benchmark its largest representative repository before adoption." + - q: "Do AI coding agents replace software engineers?" + a: "AI coding agents do not replace software engineers because engineers still define architecture, establish acceptance criteria, control permissions, review risk, resolve ambiguity, and own production outcomes." + - q: "How do you test an AI coding agent before buying it?" + a: "Engineering teams should test an AI coding agent on frozen repository snapshots with identical tasks, permissions, hidden acceptance tests, multiple runs, and measured human review time." + - q: "What permissions should an AI coding agent have?" + a: "AI coding agents should have least-privilege access to only the repositories, files, commands, networks, secrets, and branch operations required for a bounded task." + - q: "Should an AI coding agent be allowed to merge code automatically?" + a: "AI coding agents should not merge high-impact code automatically unless the organization has validated tests, policy checks, approval controls, branch protection, observability, and a reliable rollback procedure." + - q: "Is Gemini CLI open source?" + a: "Gemini CLI publishes its client under the Apache 2.0 license, but the hosted Gemini models and associated services are separate from the open-source client." + - q: "Is n8n an AI coding agent?" + a: "n8n is not a dedicated AI coding agent; n8n is a source-available automation product that can orchestrate external coding services but does not replace repository-focused coding agents." + - q: "Is Sim an AI coding agent?" + a: "Sim is not a dedicated AI coding agent; Sim is the open-source AI workspace that can orchestrate issue triage, coding-agent calls, policy checks, human approval, and outcome tracking around a coding task." + - q: "Can Sim require approval before a coding-agent patch moves forward?" + a: "Sim can require approval by pausing a run with the Human in the Loop block and using a downstream Condition to route the workflow according to the reviewer's approval or rejection field." + - q: "Can Sim stop a patch that fails a guardrail?" + a: "Sim can route a failed patch away from approval or deployment by checking the Guardrails block result with a downstream Condition, because the Guardrails block reports passed or failed but does not stop the run by itself." + - q: "Is Sim open source?" + a: "Sim's core is Apache 2.0, while code in apps/sim/ee is under the separate Sim Enterprise License and requires an active Enterprise subscription for production use." + - q: "Can Sim use local models for a coding-agent orchestration workflow?" + a: "Self-hosted Sim can use local models through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise, although the dedicated coding tool connected to the workflow may still use its own hosted models." + - q: "What is the difference between an AI coding agent and an AI workflow agent?" + a: "AI coding agents specialize in understanding and changing software repositories, while AI workflow agents coordinate tasks, models, people, and applications across a broader business or engineering process." + - q: "What is the safest way to use an AI coding agent?" + a: "The safest way to use an AI coding agent is to isolate its environment, limit permissions, require test evidence, review the complete diff, separate approval from deployment, and retain a reversible change history." +--- + +## TL;DR + +AI coding agents can handle multi-file refactoring and complex debugging when they can inspect repository structure, follow dependencies, run tests, read logs, and present a reviewable patch instead of merely suggesting isolated code snippets. + +The best overall choice in this comparison is Claude Code for deep repository work, while Cursor is strongest for editor-centered refactoring and GitHub Copilot is strongest for teams that want coding agents embedded in GitHub governance. OpenAI Codex and Gemini CLI are also credible options for delegated and terminal-based engineering tasks. + +This guide evaluates coding agents specifically for refactoring and debugging. It complements Sim's broader category overview, Best Agentic Coding Tools: IDEs & Platforms Compared, and its reproducible AI coding-agent benchmark. + +## What are the best AI coding agents for refactoring and debugging? + +Claude Code is the best overall AI coding agent for refactoring and debugging in this comparison because it combines [repository-wide context, command execution, and multi-file editing](https://docs.anthropic.com/en/docs/claude-code) in a reviewable workflow. + +As of October 2026, the ranking is: + +1. **Claude Code — best overall for complex codebases** +2. **Cursor — best for editor-centered multi-file refactoring** +3. **GitHub Copilot — best for GitHub-native engineering teams** +4. **OpenAI Codex — best for delegated cloud tasks and parallel work** +5. **Gemini CLI — best open-source terminal interface among the tools compared** + +These rankings assess documented product capabilities rather than treating marketing claims as benchmark results. Teams should validate the shortlist against their own repositories, tests, logs, permissions, and review standards. + +## How were the AI coding agents ranked? + +The five AI coding agents were ranked with a weighted rubric that prioritizes refactoring depth and debugging effectiveness over convenience features. + +Each tool receives a score from 1 to 5 for five criteria: + +- **Refactoring depth — 30%:** Can the agent plan and modify behavior across files, modules, interfaces, and tests? +- **Debugging capability — 25%:** Can the agent inspect failures, run commands, analyze logs, reproduce defects, and verify a fix? +- **Repository-scale context — 20%:** Can the agent discover relevant files, symbols, dependencies, and conventions without requiring the user to select every file manually? +- **Permissions and control — 15%:** Can teams constrain commands, review changes, isolate work, and prevent unsafe operations? +- **Team workflow — 10%:** Can the agent fit into pull requests, issue tracking, code review, and shared engineering processes? + +The weighted score is the sum of each 1–5 criterion score multiplied by its stated weight, rounded to two decimal places. + +| Rank | AI coding agent | Refactoring 30% | Debugging 25% | Repo context 20% | Controls 15% | Team workflow 10% | Weighted score | +|---:|---|---:|---:|---:|---:|---:|---:| +| 1 | Claude Code | 5 | 5 | 5 | 4 | 4 | 4.75 | +| 2 | Cursor | 5 | 4 | 5 | 4 | 4 | 4.50 | +| 3 | GitHub Copilot | 4 | 4 | 4 | 5 | 5 | 4.25 | +| 4 | OpenAI Codex | 4 | 4 | 4 | 4 | 4 | 4.00 | +| 5 | Gemini CLI | 4 | 4 | 4 | 4 | 3 | 3.90 | + +A one-point change in a heavily weighted criterion can change the ranking, so engineering teams should rescore the rubric after running representative tasks from their own backlog. + +## Key facts at a glance + +The five AI coding agents differ most in where they run, how they obtain repository context, and how organizations pay for model-backed work. + +As of October 2026: + +- **Claude Code** runs from a developer environment and uses Anthropic-hosted or supported cloud-provider model access; Anthropic offers [subscription and account-based access](https://docs.anthropic.com/en/docs/claude-code/quickstart) and publishes [official plan pricing](https://www.anthropic.com/pricing). +- **Cursor** is a proprietary AI code editor installed on developer machines, while its model-backed features use hosted services; Cursor documents its coding-agent capabilities in the [official documentation](https://docs.cursor.com/agent) and its commercial plans and usage policies on the [official pricing page](https://cursor.com/pricing). +- **GitHub Copilot** is a proprietary service available through supported editors and GitHub workflows; GitHub describes its plans and usage model on the [official plans page](https://github.com/features/copilot/plans) and its capabilities in the [GitHub Copilot documentation](https://docs.github.com/en/copilot). +- **OpenAI Codex** includes local command-line and hosted task experiences backed by OpenAI models; [OpenAI's authentication documentation](https://developers.openai.com/codex/auth) explains subscription and usage-based access, while the [Codex pricing page](https://developers.openai.com/codex/pricing) describes current access options. +- **Gemini CLI** publishes its client under the Apache 2.0 license and runs in a local terminal, while Gemini model access remains a hosted service governed by Google's quotas or API billing. See the [official Gemini CLI repository](https://github.com/google-gemini/gemini-cli) and [Gemini API pricing](https://ai.google.dev/gemini-api/docs/pricing). + +A locally installed coding-agent client is not the same as a fully self-hosted model stack. Buyers with strict data-boundary requirements should separately evaluate where prompts, repository excerpts, logs, telemetry, model inference, and retained task artifacts are processed. + +## Can agentic coding tools handle multi file refactoring? + +AI coding agents can handle multi-file refactoring when they can map affected symbols and dependencies, edit coordinated files, run the relevant test suite, and show the complete diff for human review. + +The important distinction is between changing several files and preserving behavior across several files. A reliable multi-file refactor usually requires the agent to: + +1. Identify the public contract that must remain stable. +2. Find implementations, callers, tests, configuration, and generated types. +3. Propose a change plan before editing. +4. Make changes in small, coherent steps. +5. Run targeted tests after each risky step. +6. Run broader checks before declaring completion. +7. Summarize changed behavior, unresolved uncertainty, and rollback steps. + +[Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Cursor](https://docs.cursor.com/agent) score highest here because both support active repository exploration and iterative edits. [GitHub Copilot](https://docs.github.com/en/copilot), [OpenAI Codex](https://developers.openai.com/codex/cloud), and [Gemini CLI](https://github.com/google-gemini/gemini-cli) can also perform multi-file work, but the best result still depends on repository structure, available tools, model behavior, and the quality of the verification loop. + +## How do coding agents build context across files, dependencies, and logs? + +AI coding agents build useful context by combining repository search, symbol discovery, dependency inspection, command output, test failures, and user-provided operational evidence. + +A coding agent should not load an entire large repository indiscriminately. Effective context construction is selective: + +- **Repository structure:** The agent identifies packages, services, entry points, tests, and configuration files. +- **Symbol relationships:** The agent traces definitions, imports, callers, implementations, and interfaces. +- **Dependency metadata:** The agent reads manifests, lockfiles, build scripts, and version constraints. +- **Runtime evidence:** The agent examines stack traces, logs, failing requests, and reproduction steps. +- **Project rules:** The agent follows repository instructions, style rules, test conventions, and restricted paths. +- **Feedback loops:** The agent updates its hypothesis after commands, tests, or static analysis contradict the initial diagnosis. + +More context is not automatically better. The best coding agents retrieve the smallest set of evidence needed to explain the failure and verify the change. + +## What is the best AI coding agent for debugging complex codebases? + +Claude Code is the best AI coding agent for debugging complex codebases in this comparison because its [terminal-centered workflow](https://docs.anthropic.com/en/docs/claude-code) supports iterative investigation across source code, commands, tests, and runtime evidence. + +A strong debugging agent should be able to move through a disciplined loop: + +1. Reproduce the failure. +2. Form a falsifiable hypothesis. +3. Inspect the smallest relevant code path. +4. Gather logs or command output. +5. Test the hypothesis before editing. +6. Implement the narrowest defensible fix. +7. Add or update a regression test. +8. Run targeted and broader verification. +9. Explain residual risk. + +Cursor may be a better choice when debugging is primarily interactive and editor-centered. GitHub Copilot may be a better organizational fit when issues, pull requests, review controls, and repository policy already live in GitHub. + +## Which AI coding agent is best for multi-file refactoring? + +Cursor is the best AI coding agent for editor-centered multi-file refactoring because its [agent workflow supports codebase understanding, planning, bug fixing, and diff review](https://docs.cursor.com/agent). + +Cursor is especially suitable when the engineer wants to remain actively involved in each step rather than delegate a task to a remote environment. Its strongest use case is an iterative session in which the developer scopes a change, reviews proposed edits, runs checks, and corrects the agent as repository-specific constraints emerge. + +Cursor should still be tested against monorepo boundaries, generated code, language-server behavior, and the team's largest representative refactor before adoption. + +## Which AI coding agent is best for GitHub teams? + +GitHub Copilot is the best fit for teams that want agentic coding capabilities integrated with GitHub repositories, issues, pull requests, policy, and review workflows. + +GitHub Copilot's advantage is organizational fit rather than an assumption that it always produces the best patch. Teams already using GitHub can evaluate coding-agent work through familiar branch protection, pull-request review, ownership rules, security checks, and audit processes. + +GitHub documents repository research, implementation planning, branch changes, and pull-request review in its [official Copilot cloud agent documentation](https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent). + +## Which AI coding agent is best for delegated cloud tasks? + +OpenAI Codex is the best fit in this comparison for teams that want to delegate bounded engineering tasks to hosted environments and review the resulting changes afterward. + +Delegated work is most effective when the issue is reproducible, acceptance criteria are explicit, required commands are documented, and the task does not depend on undocumented knowledge held by one engineer. Parallel execution can improve throughput, but it can also multiply review load and create conflicting patches if task boundaries overlap. + +OpenAI documents background and parallel execution in the [official Codex cloud documentation](https://developers.openai.com/codex/cloud). + +## Which open-source AI coding agent is best for terminal workflows? + +Gemini CLI is the best open-source terminal interface among the five tools compared because its client is available under the Apache 2.0 license and supports agentic work from a developer shell. + +Gemini CLI is a strong candidate for teams that value an inspectable client, terminal-native operation, scripting, and extensibility. Its Apache 2.0 client license does not make Google's hosted Gemini models self-hosted or open source, so teams must assess client code and model-service terms separately. + +The license and source code are available in the [official Gemini CLI repository](https://github.com/google-gemini/gemini-cli). + +## What engineering teams should look for when evaluating agentic coding tools? + +Engineering teams should evaluate agentic coding tools on task accuracy, repository context, permissions, observability, verification, and rollback rather than on demonstration quality or code-generation speed alone. + +### Can the coding agent solve representative tasks accurately? + +A coding agent should be measured on real defects and refactors from the team's own codebase, not only on greenfield coding prompts. + +Use a balanced test set containing: + +- A localized bug with a clear failing test. +- A multi-file interface change. +- A defect that requires log analysis. +- A refactor that must preserve public behavior. +- A dependency or configuration failure. +- A task where the correct action is to ask for more information. + +Record first-pass success, final success after intervention, regressions introduced, human review time, and total compute or request consumption. + +### Can the coding agent obtain the right repository context? + +A coding agent should identify relevant files and relationships without requiring unrestricted access to every repository and secret. + +Test performance on large directories, monorepos, generated files, vendored dependencies, multiple languages, stale documentation, and ambiguous ownership boundaries. Ask the agent to explain which evidence supports its proposed change. + +### Can the coding agent operate with least-privilege permissions? + +A coding agent should receive only the repository, command, network, secret, and deployment permissions required for the assigned task. + +Look for controls over file access, shell commands, network access, secret exposure, package installation, branch creation, force pushes, and deployment actions. High-risk commands should require explicit approval or be blocked by policy. + +### Can the coding agent show what it did? + +A coding agent should produce enough evidence for an engineer to reconstruct the task, commands, tool calls, changed files, test results, and approval decisions. + +Useful observability includes task inputs, retrieved context, command history, diffs, test output, model or agent configuration, timestamps, and the final rationale. Sim's guide to AI agent observability provides broader guidance on traces, metrics, and evaluations. + +### Can the coding agent roll back safely? + +A coding agent should make changes through reversible branches, commits, patches, or isolated workspaces rather than modifying production systems directly. + +A credible rollback plan identifies the pre-change state, the exact artifacts changed, database or schema implications, generated files, dependency changes, and the commands required to restore service. + +### Can the coding agent prove that the patch works? + +A coding agent should verify a patch with regression tests and relevant static or runtime checks instead of treating a plausible diff as completion. + +Require the agent to report which tests ran, which tests did not run, whether failures predated the patch, and what uncertainty remains. A patch without evidence is a proposal, not a verified fix. + +## How should a team benchmark AI coding agents before buying? + +Engineering teams should benchmark every AI coding agent against the same repository snapshot, task definitions, permissions, time budget, and pass criteria. + +A reproducible evaluation should include: + +1. **Frozen task fixtures:** Use identical commits, dependencies, and environment setup. +2. **Hidden acceptance tests:** Prevent agents from optimizing only for visible assertions. +3. **Multiple runs:** Agent outcomes can vary, so one successful demonstration is insufficient. +4. **Human-effort tracking:** Count clarification, correction, review, and cleanup time. +5. **Security review:** Record attempted secret access, unsafe commands, and unauthorized network actions. +6. **Cost tracking:** Measure the actual billing unit used by each product without converting unlike units into a misleading comparison. +7. **Artifact retention:** Save prompts, traces, patches, test output, and reviewer decisions. + +The reproducible AI coding-agent benchmark provides a complementary framework for testing debugging, test generation, and refactoring instead of relying on a feature checklist alone. + +## How can Sim triage an issue, propose a patch, and request approval? + +Sim can orchestrate issue intake, evidence collection, coding-agent invocation, patch review, and human approval while leaving code generation to a dedicated coding agent. + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Sim should not be described as a replacement for Claude Code, Cursor, GitHub Copilot, OpenAI Codex, or Gemini CLI; its role in this workflow is coordination and control. + +A practical workflow can follow these stages: + +1. **Receive the issue:** A deployed Sim workflow receives a normalized issue payload containing the repository, commit, symptoms, severity, and links to permitted evidence. +2. **Classify and enrich:** Sim extracts the suspected component, gathers approved logs or ticket context, and checks that required fields are present. +3. **Create a bounded task:** Sim prepares acceptance criteria, allowed paths, prohibited actions, test commands, and a time or request budget. +4. **Invoke the coding agent:** Sim sends the bounded task to the selected coding-agent API or service and stores the returned patch, explanation, and test evidence. +5. **Evaluate the result:** Sim checks whether the response includes a diff, regression test, executed checks, unresolved failures, and rollback notes. +6. **Apply guardrails:** Sim's Guardrails block reports passed or failed; a downstream Condition must route failed results away from approval or deployment. +7. **Request human approval:** Sim's Human in the Loop block pauses the run and resumes with reviewer form fields. Approval or rejection is a field, so a downstream Condition must route the workflow based on that value. +8. **Open the review path:** After approval, Sim can call an authorized repository service to create the review artifact. Rejection can return the task for revision or close it with reviewer feedback. +9. **Record the outcome:** Sim stores the decision, evidence, patch reference, and execution result for later evaluation. + +The safest design keeps repository writes, pull-request creation, merges, and deployments behind separate permissions. Approval to review a patch should not automatically grant permission to merge or deploy it. + +## How does Sim compare with n8n for orchestrating coding-agent work? + +Sim is better suited to AI-agent coordination when a team wants an open-source AI workspace with visual agent workflows, model choice, human review, and agent execution, while [n8n](https://github.com/n8n-io/n8n) remains a strong general-purpose automation incumbent with a large integration ecosystem. + +Neither Sim nor n8n is a dedicated coding agent. Both can coordinate external services, but Claude Code, Cursor, GitHub Copilot, OpenAI Codex, and Gemini CLI are the tools in this guide that perform the core repository reasoning and code changes. + +Sim's core is Apache 2.0, while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. n8n's repository uses the custom, source-available [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), with separately licensed enterprise code. + +As of October 2026, teams comparing the two should evaluate the workflow they actually need: model-backed reasoning, approval state, traceability, repository permissions, failure routing, and integration maintenance. + +## When should an engineering team not use an AI coding agent? + +Engineering teams should not delegate a change to an AI coding agent when the task lacks a reproducible objective, the agent cannot access necessary evidence safely, or the consequences exceed the available review and rollback controls. + +Poor candidates include emergency production actions with no isolation, undocumented migrations, changes involving inaccessible hardware behavior, repositories with unknown licensing constraints, and security-sensitive work for which the agent's data handling has not been approved. + +An AI coding agent should also be allowed to stop. A system that always produces a patch will fabricate certainty when the correct outcome is a request for logs, a missing reproduction, or a human design decision. + +## Which AI coding agent should your team choose? + +Claude Code is the strongest default for complex refactoring and debugging, but the right AI coding agent is the one that performs best on the team's repositories under its actual permissions and review process. + +Choose: + +- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** for terminal-centered repository investigation and complex debugging. +- **[Cursor](https://docs.cursor.com/agent)** for interactive, editor-centered multi-file refactoring. +- **[GitHub Copilot](https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent)** for GitHub-native team workflows and governance. +- **[OpenAI Codex](https://developers.openai.com/codex/cloud)** for bounded tasks delegated to hosted execution environments. +- **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** for an Apache 2.0 terminal client and scriptable developer workflow. +- **Sim plus a coding agent** when the engineering process needs issue triage, evidence gathering, policy checks, human approval, and outcome tracking around the coding task. + +Do not select a coding agent solely because it generated the most code. Select the tool that produces the highest rate of correct, reviewable, reversible changes with the least human correction and acceptable security boundaries. + +## Related comparisons + +Sim's related guides separate broad coding-tool selection, reproducible evaluation, workflow-agent selection, and production observability into distinct buyer questions. + +- [Best Agentic Coding Tools: IDEs & Platforms Compared](https://www.sim.ai/library/agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare) covers the broader agentic coding category. +- [AI coding-agent benchmark: a reproducible test of debugging, test generation, and refactoring](https://www.sim.ai/library/reproducible-ai-coding-agent-benchmark) covers hands-on evaluation methodology. +- [AI Coding Agents vs. AI Workflow Agents: What's the Difference?](https://www.sim.ai/library/ai-coding-agents-vs-ai-workflow-agents) explains where coding tools end and cross-system workflow agents begin. +- [6 Best AI Observability Tools for Production Agents in 2026](https://www.sim.ai/library/6-best-ai-observability-tools-for-production-agents-in-2026) compares observability products for production agents. diff --git a/apps/sim/public/library/best-ai-coding-agents-refactoring-debugging/cover.jpg b/apps/sim/public/library/best-ai-coding-agents-refactoring-debugging/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5add2f3f8ea7a5e4530b5a0c8d70493ed07c9955 GIT binary patch literal 29218 zcmeGDWmH^Cw?7Iu?(Xg`3GM_5?(XjH1P=rW?(Xi;cyI`=jk^VRf)gy<&fd>H<2mOY z@BfbH-uv;aZWwE=s+v{xt69@(_S^E?8bBNX`}YM02aA9R{-7WuAt9q+qoRR7*ko9^ zSl}-i5d{$e_>Ya2l9HB<_Z`bS-j6~;A2n4pot;zv5P|rAO{5Qi0S{ROT?Yk034p|a zfWm-y8vqakAOMh15D*Z6|GHq|pkd&lApewN|NHyDE4-}&kfFeB(4o-5FPeM*tJ!~i z1M|U*DE~ihC1ET9Dun@6S5E_=-*4L5@u`?t-fZlMmf@fjItf3ndFehgzJovVL;Ou^ zphxPFgB$ptbulM-SkI1xHkyQ#o8Hz7(9o={4%SI-DQ>yvnk-aVoK&z+*0>ROk2qCY z$0;aotCC}$pYEcVq?7y4y7@RL*ecoXTgpU~%ftwPAWm2;ex^)H&LoiVc21#bRMVru znHAiarK_>ASo|_x!(2Zp#PXkZf)PBHt52!@XJC?db1GG<(30Yr z68u4^=3aUs0(A|qgzeRdZZiTdXKa|4x-*~A*Zu*1003<4C=Vq{$p5+rEM`%=N&)~- z$P;S5l(T=o`}hCfLErf{x69h!M}2o3fk5vl@0h0Wh?rLNX_Wb{hbx!!3JUrjB^C~p zb5oo+;^vlyh6JuFB0l8vfsUx_GdRsl_@%PiKayH_KwN#3cgk2k6p*t>c1mxiaBZ7& zH)JuKX0m-b%EX%kG&jD#L_Q8xGtI&ic0$Slc_LUOB+BOtqK2tvsV)L;7?0l7l!{$+ z+aGZl+fEinE|6fCbjo!`Bo0Evy7PC}iKRTVe&W9v??z)NKv~$HoOL}hRV!IN6k73W z7BDfCE$_E+QJzy>wK-wGbt^7!HN;5NE%J}5H(VC^_xS<|v=`On1{h+K6J+mr6K1Y* z?nC~bxb1!VSAO>of?JYjaCrsl#;5k|26{SA?A47GL?5ewvXz&d*q@|TU=qsLPEbhl zv~>=J_1zC;<1^z5yw{HjA+HYm7Bl=Uu?@#v%2>%M<_Oj2w{F#|W5%fXXWsy&td5_l z?>)b0s@w+_Ptt*^e3~)LMr7HSJc-t!%j#`$hZN^2#!kIUJT+%d)oHX9m@Bn+EWgt$x0 zyl%|XTLMg*GXm6&GEF@!hxZ_x8cxMkT-3f{OnLU?$h&K3wtxG*gR*nxcZ{el|Z)Uj{2*6{2qdQxU}*`E1ww05kNpd43qnZ52Cwg zoO*)!a0h9g@4LC7hIgq=Ec|0ECxkuL*lrZ`FZ*dFKJH{y2v;r&1%*B&1I+eN{9r+G z3CHI|)wJon_bYX<-^jpp#G&Yx-*XIqZMA#x{2r0YDWD$@g-35<8H~kj{f)F>`{F%E z@BbL9ADILGCa3q1GXUV1C%rhb68NtQD8k-}|5X>Fj0f31-Z*0tf0N5s1OOl`vK<)z zS1CTMuU^An_Yf7_;0m0+MMwWM{A=&nRUWY?EV(D!N30Sr&6IWY5i6qWBqXSVj*||S zziJ;Wd_|!fVV-ypMr#rP)DdoY>#evKpEa(&>>i^pCEYh#653-bMxBk>PCRZA(Z-=C z6&RZDU)>Y_3i=@77vXDE2cYyx?6cu9DH@_K*ZR)u=O5Nhu5?7CTE+b6CuYbb$J)d+ z@j3`{OP#%|7{_xGGm<(zAeUDr2+EP##P;UR*2*n@L`lyB!n^PaDw&g7`v{v1OhRp7 zQkyCg9z}eRs2`275b7t#U3YV~C?Hb(2rQ-Ty%9psV|R}YNTkb4NQ8k2xT$}5@IFKJ z>uvzU^Y`_asC+Jflju8%-X$wE)d@9ejM5&8(_^h3&n)UV5kHw&QW%5#EVPJD5(i4} zsqmEz;aQ7PTR*fz6{q4GlPdov-~KdrDDF>LcTdM}_@1oDMn|VExNkAp9~%WM8xCSy z>u>3boeJ&Tc6uvl_6=wjHuo9uj!C^)j@32QnX2&Mi}n&7RP=6PAUrTK4~AyR>boOq zrXpVDSeOuKOP?5|dt}^J?BsSZuXJ@h3X0l}qq0pbUx-(x1z+9ctMM0-O+FqjTzyu< z`UO#v;c$kPXQ>+L+|75B|9E+9Pke@z^Td(;#9UpZ+cEz|%!j+4b8$lkOj@}1oE=hcPjxBnjnP5c+$v2!)U(E2cWyeozAcCGRzHh_Q{jS zpoZD?!wh7R_T5?htLnO{HTc5zKbmvV?NIb)d&ci{w&}aQ)r>0E?g17zOR^-uAbx7->^rep%nCeQX-~>IWavsm|o3;rd7qrKhh) znUN{~rL8{n6KR`-ip2pmqb-9CO(Y|c*|L#M1Q1h3N8`4AN*Sc3ikxlr_VeJEVF z{(mZvsxZU{$$SF*TlwV!IuPN+6u)rR9*Y#6ZeL;{_MuY(yv~9+%iy z1Og!Gu9PtKy~DgK>&qETZ2!F_0L8$s)u2FBp)HK(^$G<3=cy<98s2Yc1WqM8w~ z+g^_JoE5Q%tNt=o?*FYAQq5t`VtgBZJ~M|nah;KV?;hIS`2RF`005og3_i$6o#j>y zlO=%9aochzl7F-EVkf`Hd%QqCQuiQ#^x%sbD)$Y%-*ibG5I9@aTyd;o7My!_j%?kn z-9|UOp%h}V|7?v^cJm;woOTB{BPFzP8~VeEudJl4xTnQuGGlV%j|fnogAe%8%;hT` zB=7F@VU;$F<k7|VI)!HTn79#uk z;s5(p*iCl03(B=C{~h#;e#2m;jxs5*qOc3DioqeJtW}s1DGEODX^K78eRExRHnqK_ z4S;{UvCP-MdOOTu2Xs0zRarV5m9VRwm?bcQsv}Q7}F$eZG*!EEe~$BxC(aLfz=~gU)0GyLtT} zmz(s@h2KJh`(uOUE=iPpcZ<)OUf!M**$a8;lxEB(0{C3|YzfPRCxoKUwa)IM5_1cc_wVxy#$ai2ol0{&SZsfK|_2dvK`M(!3R`q))gs@Hi*W%ZqiTgYG^N1hmud2S8cRaiG=GLs&oO5jPo-dL>IDrs65{T|)B z(Os-m(tOwR`8)_bvF6|0V-n8PVdSrDBd)2o)x)wh;S!GbeHlY^;TmvgDpY4(cX?b! z-xhDnZf%cu=q_I_e?G=Nyrx-H2Qvs@*v8r$MK)8{t;lee5lQm`!g*?w{K@xY5%)yx}!;4t^si;j?kd{T97 z6!$+ks6BDHUt#?U@cQ&J{Coj=W`F_Wd|naEwbNaYIT?KTZPK2}VU;n)PpCaU#WY*3 zb(p~GnO=SUlXc<8d=o!(*%9I$9~*gY{yz5J$?1A!zZo-_H!Fhx zy_5;q1?m!V@sDHbG6hke|3p)`eglLodRoR1ikN;DdKRqE6lDW<&ZXtqhI*Fpnj~3B zPYR6-J6n?A0smo-)cG`|wGeG%9m~9%NX>Kp5Th7)&HQYK8f;^IP8vlcghPC_9}}lj zPxj5jCuk03d3t!C3YUUp!WG zzz>WBuCuE+5TQuoI_gwcR@QbkwK~YtBY-SPD%npQVd>BTisM^4H_~SsH^xi(&)Y&W z9)xown7fb9r)cg4fgzIZ;@XOw2V{^3>2l93Z-B;_$qyG?TS+@8*Wp#=LoKzXleUCE zW-5*B9q?YFgjf6XjT*5^?|ZF-wRsmLGL%FIyc-coE;Gw`$|V{4QOvG&(4U{0kfnJvqF)Ip3p@=pam8>hfv{ zTb3mT)S*c2^>FBqqbbb}4s{sTIW$G5unJO_2d;t!+=rMET^L7B9qLM}Sn7k41ZSf% z_waK|$oHc`6}!CZ?mDdKtydi;NeGnEW{s_8f(IIP!WmDex$B_279?BANeAba4!-(x z?9$S{WF2-p z3Wtzbaj5YyCfcl8(vdaE?u8OinwHJbbgNtxMd|uV$YEsn`*k^77tiHA1aShzA6jBN zPkeQH4h-5WDzNERMLX8iK~@)NWeuik<(lGYVkJbIU178PAvcFuppZu1@(p}AUQOqL zS>rnl$pab4vY6lsKYwP4rWQS4x0+O&8I<=^nJ-Ge!u*@RO1C-@o3dqj&J8EOysF*g zmdNV%rD}c?K+;S#w5<-K?Za0M^~t0_%q)hfyVr8$a1QYvY7C$eMycWhxn=KTFPKC8 zXzuQA>rxwRzo?RpqeQT;x9>OeYr9qliv=+0+u(P-x7sS$XxhP=`28`4zj{{q zg9{4letBwsWpyNTWZVBxqon0JfyI@|Tgm&SxqkS^7J=-Xtr3bSib8nPmwev4rE_uD zr7*&5b~IHBnhUsC&oC_g^ej7kUpdO46CGf+mOO=?gNvOk9r2b#u#pGuF_=xfLZ28P z3qQ48Em=9tz?OF^AGc1X=^AhqCoZOIRXreRT=$0Lc!GH^7M15_T~ z;GF;M8J#3vjyGo}4twB{=exFyHF zJ=5WQ7bjfQl4yN2BeOiIKXxlY^jOeG9Q?vSNEJ11TY0i?(6KD~^%@v92TNRrSsjU6 zi#z4)pOKuw!{|5HJX6Z4Ltx#%VQBl!UP{-^F8v9K!E{zj$@eUNk3xVBUequWM}5d% zi%!36^Vhp8WU%P_LaD$encN60264pNw}UmD_xN06o6Iea;ZMPB>@H3X|HZ!g-`dZ4 z%>EOlZrP8AI<-$T5ISh?HzDn7fgo1WDdvcV4m95TyN!mHdt+6d`$e*-0Fw($#$U8% zxO9_FCJPl!h^9QU`GuB-d5fT9XCnr zw}qJKB9oLDaVA0UlgBwD&9vhuKOp{s{OdO>*4h= zD^-wNj@TDTIgO9@U}JzYb`Uj7YaO{Q-_zB*=ia5Q>~*N4sF{lo^1J1CD2s-)9#ILT zcgjB;9mSJfTuXy_P+`#K29&n{@b3*Dh%I|ZXQDB~g4hgu%!SnHDpmtQ*ycLf6J79I z_b94e!a#1-(C0z09^!|2X5)8Y35C#cmt{-d$ColTVsGAoi=1Qcyh7?7m^Hw`_*jB3 zshZ5woQLPZ-CJ-*Nb`}TUgyjnu_KH){mE&yd4ZFOdS-Ua#SzHW2qq}z(twfeqhrok z>!}ctWb7g_K|(wSO`QD(P>Mdh{RQIIaSg7)JjeJtz}m!j@vqJNV+}{fPS1)&$uu&{ zl0idH<8ElIwmab@=bHoS&fd{M4(?Eh_Q#ryzIagCXHHrV@g2>T$(0Ub3q>^Nr_ z{Ed)N20GqK0VeHmLx%Q3#@dr`$^`nB=jF7dDdy_`xw{wg989JKO+}HSn_=y=ajBbG z!u7<8A%XNJm6PAjQSTJ4T2&Isef*FOw6)9MP5Q6#5mrBZ1_R{CqK1Bj2+uS8U{5GH;IKA+{%TrlUW(p7Wk zkMC$_!bx^rY-2b}TE^#DTQ(h|g;4~{BLiJW<4sN323*32OMmw)Nk>yuzJK&BXk_U> z9}+CPt9U_4L0;#J@^xp%TFEx#8;Ur*93V6a(4i6T_RTUoe+TgRqDuZ#P4$RCg-V1^ z#&8CG%@gtT{^||jU7^2lx0N^};M9QLf>rAa+b(NLNB?C%bNPVM(D$pRhD1q}GxaLh zs+1`(mm(mt+dAjM&tqlH!)i_29qHcG&0_KNle$CsAaY=?vGg{+{w~B?A;tS2@;Rt{ z<5g};0VYSq5|{HLzaGR73_dmg?nhJz3-4bxb5?cD{v>AZn|@l8s>It;|Fb0^a%2e= zK_eP@sVGBGE85L&!SSKSw3F5z2c(1E6*04LP>5j{#@Eibk3BEMrKb5WYwdQm`-jCO zRAbU)g-3PLh5_$n@=El!+#z>^jzpGYgB((TAY(0?3qgvJ_L*>;5*sec_*8038iL4u z*+0AdcD1?lTP<}I5~`+FHp?0}lHH`MVco&rvp9{Ktg#(#*Utv=%q4%F->fRpyYxzX z6^~#Q`^XreO5gwZOd=y&JUaGi23SJGo8nKW&k}25(Z0&$mOY|A_9d0}gV;yx5mV@0 zeTnGDo%Yj8^xAH8cu7kF+R&=d#&xoq`B@3q$`@KCaOsH+Wt&C-7Tu6bb^!7&=8y|h z^Srqndk+7J=uL!Hv?=cp+w2oPEx3+HGJ7773YuJH0`{1GM|;A;u_f~CaYCXuDEfz< z22=Z}jssLIOTh9TEJb=4LV@cQ*ks`q8&T{Kmdz_JA{bK68&$g*pF@);a)_&G-@d6m z6XQV5tR*i93A3lNb5jf6PPhfoAx@YGr_~y&zxKgFQ*bcH%m+!Z@&<8LYRPX~=y6XR zVf}>7QL?H$2|$RhsYJgUcUy)LZVyD0hBR;au5+Xs8aBm2o8v)3Fl6%A*vJ2EU@y(O z<7X+~Q0e=`xnlZKn7MTlI4Ko5w=MLk(PM_*Yt6w68P+1>2lY?$s6rx7NexW5c{M)3 z9Vt_g5PvK`r8i?9$DWhE8*2CV>Q6zlk4#}I9a+(OrX8*nZE8?1&UdBmtf|psYTVb& zA>Uz+UY?Ng7&4$9Pm5T=`>Qr7fkQ)G0e0U_ zzUlfmlq;RV`{e7DNxT763lIP9SW+e1jQ0)T_(#;}O5rXcF=f)dAI>fzvT0!w@&pc^ z`7VQ)O0vYaEqvcrQBDN;3-X#P4US}bB7ZIOBBCLBH-^w%J|9Zn8A^jZHSW+1&HBc7 z(kSzo1+Ad43@SpesPf7EtsY=VV;BB zvz^Cz)kMEb!NzKNK~@gvq~VSSlY$1Z+?K$kP#3#vX*I22>ykIXV zio|i9c@V@^w276vW`F})liN(5;S)>vw2(T5L6JILZw}pHbFXZ$_iBX>=z#CS9z z^WwPIl@sfvRIRXs758XGw3xG|70_)>~R8q_Mv$r-ya*WoANW-Bvtp)LVP_Z6l@yC7A017 zi=N|mGstRq_x46OuIDQbQU1&_w$yqC(K^Zm#PS(y+MNCiZb95&v>eeGqpxQ)MmaHR znYk~jhKctR2BGHdXXovI%wDh^&DSp2a9!LI3LocTUdXiyaRp>A&y>L&ho%BY0I!0Y zS&_e7826@HXv;9&hFNML0UgR4G~~kv4h85KrsSUe480;=K5(L5DsdYQOr?WdvaOJ~ zN-sM$br(2NKQQ3||T( zj_jeO6KpH{{m}$A;!D1(jly1`^8QHg4&t)6&-l8H7?ai1J%Y&1dKnuNqpb)e<%h!U z6$$zOuoW{pVswMP;YqoP10>cCiq}nViBVz%d4w&QW9{Lez^F7KwBSJ56naHmfyK+r zO}5Zip0$15g5%g09IJNNDDP}j?FsW`?U0G90MJ^<- z3gg$+3}}XBB?u(7j~%>Ps+cA>Nr%BRU~Kk~vk9Z_H&Pv{5L<-MZJmYgkT>B_6T|(B zX^zqwD-JG`*24&=Q)()vipy2}Kh9~~{=~5yP))-|W~&ay&&$WU0mpftV0dtWCEDs= zB^Mr`SglF2bNS4`0c(ylLpwDgPndio-XY1J1x*G8Z)~uy)Yhnx72g;O)xwF-GCSnL zY{t7MOZZXV`fAC`Ve-TI;JS^WqwY|(vs{r^kpZb!s6B^kyLbLV1~)C}FQEj6%HQbU zcm!~^`*|&@?T^fv7WgjoU$ne}G+p6wZY5 zbXjtC0^n+M!WH%bi+!>XBJcWPYO^)6YzJGX)vWMA;86EezM6|zP+|;Q*k76sc6n3= z=XRww20lYpeJQJ^vXdVyRm^x(*|NDnlr_nX-_0TdIXNucO2-*<+tra}+edB&FblG4 zu?VI7fmwH$ne{b89hd zpgQe~^?xDchjZn*Z1ZB|Kh8jW;MZx$XLjGP($6zGx_n~qjkb902Zx6X4MAn8Vz_-C zQ^qQs6ZS=!0zHo28s2>(m!r7-&@?ulajyP97-kSx@;Vs`EBx@I*QH;U$*mh1r%rWygstwU}@V2(ay-Bl;_@|YUpP_OEem-pQ8Kp zn;Kr{W|kxXIU6Q3T$NOkYcOd$qx1J`X==`^mRO1@7r1c-anV^v3I~~MzH;TX$HN6O zBWz%(XjYPE3h{G6LtFkN5!6#k*n=r8gA2Jp3;q^j4N`j-zqH$b)mJOG~r?E)%I zF(`GNscRW$!VS7eY1qn3pA*iG)9+;2!`ujLK08G%T;3z@r+C_~@qfpiM~MDrA+Gi= zxMgL_A$6sc2hlE6=P#l`n+V5$Nw7bn|9ZVeQ@Z{QknG#~&Bw?A{rmYtg3HGfo$Nz! zQ1l;hg3ehZfS+b{2Q5s{v&Ab*k*or{B``8~*8vS8Ao?SIT<;VrmJL2OZ;T~KmUBxu zjvNEoi@!z7S=e04-Jqq$&$kFEFz z6m(m21d3JbkN*ON<^4aPSP{g!MIVDf`De}td5dL5wV<`y)!4x@Z4Dbw@mZe& z6;kpP{u_*+E!WH$JVzuWn~umJP572j&5gRT)!p1I#_Dex4qQW;ah^0d9Y5Sk*-VFh zAuG!Q=_pa?*i*ZK`-n$`Mr#@J;pmkVw@|U~NKFCoi*6=zaK;DO;vYZhZ}TF2apvM> z83Pz-MH^~{uU3pN7fE>77hM!Bipm|Ddz~s;juu$VRnybZ;~^`$M3}K9Ibf|fd}5A` zzgN8;>%tbjVyI&hF+rlaY`;ETV9J+UWdNTujY?X}Gvx+gc2V5!?^EjrR#r#w+?dSg zZ=R)cyIZBFrE+<|&>C6q)#+1-?(s<;(Jl$@(c7R|CXa5l z9U{ttEl|@l*Up$VG7ZROnW>lHS+<%+w<~2%G z0|1sNLt48ZII=XRC5S8>jE{ZLULLP-KfAp38~;tyZd!5^v&7L3&2Hk(#qTBtvkaOK z9L|HdTpg8xl$@6vokcO=zOUyJUTnI|5fHyQg)u&_ryAy*E(yf(;6>n_}&O*a* zdy*y$xBu3y)>z@TL%-|TD0FsT@52pa6*@!h(O@WhVX}?1yE$X1M_=W({)fS{qZ(z0 zR8dxtkM=X$pRI#ESY@=VU0c1G{80s_ z7zrNYwt;hHs}|ZY{g6wt`76z_LXNjw3u$3BTB4sQqcaFLImX)3BF94*vo;j-4WMY6 zVW+(dvzE`2>UTmUp&)<7B#H0B5K8q1h;GRQ)@tZ$MyEbnYG`dgtp^m&%PxGr#w)@e z*B!=ZorJJ&Jk)+-w6%)Rtct-|+$b@X}ReZdsBoiKp!4o0slqv?s78s>U#*B9L{upWWBh zXk4qEhW#@=dsmHD5x0*6_(GLf3!_s!%YAe`OJ8er!g3c#f(0q_1o448#v1zzn%5Xq z`Mqd_(=@{4E#&9%&F6K#?X4TKgl|HL@w+0obKal&D$}gR#X1!t2pR26vQ@YFWMn7t zj!_R)@U=ewQb?=k_+iwE^{V#yyueNOXBEGJhxmfUV%tUxty_ir6#uACHI$`0^i?{Q zAJZ5I|6~36-)Em|1cIQe)ka*ENy7WX#G1h9$F7G~H}GLP?_l65R8s@NdCW4%FdE<9 zEn?eo{RcunmioNrj!43j1MJ??SGi2YWh)r=JgH;jOJ0?f_Ae=Ol^3!q;OvNwjggj7 z)!HGrjdvRPXNn@ZG@3p2sMpoQ1&%}m4Mju`usQ%0xY41pfHsPCS=5o)!S40Rn4}mC z-sPz!5<5q_9Y%yS?nV1BF9mg|mU?(h+im1@90u9TJRZmi#bmT2FTuuGd%cvpc2mZt z8(^x1a~u(jplpnjaq#!Uh>h%>Dz~^C-tD!71Y`;^k(C0}rM`n07-i}Rn%vcmko|Du z-Fm?(ga>X29mL7Pr-;(3W8+?nFUu+{+$|IKd4;f9`d!Xpix%T3hR**DR;P&)c*OQD z6K4IoTRo9r6ivn=rzMGa&w30Zf+|Y}UVBeGmiHjC-txZun3S)gGYPa)A zn`Dd#L#Qf7JaH;=wcyBFljJ;iw;S%KqJ6)pb*|)~B%6vp@`P~R@TE8RSDzM{G~0ZF z=6aO_EmL4fbQI<tMn2s&A_x71t)~Z}7SZnf5 z)JMx|_c6%IXjn@AzU;O$2(>c!jNd1r-*M~smuGPN0HHrkMV6Am8oNtSW*6c5aY>{H zU0mU;!-}4zKC4u9f?76JbOsY)?)R?2c#q^o1XXycTC__@2^A>0QvYOZo4;H8IZ^o{ zz_5lK7Lt?pBREK$#{qKZ9Y&~4<*S!umz={ydie6W(&@E{)z09~O5ue;q<3;CdaNF; zQ_j1&p~mNnS$t^55Hd42IlCzbTs1p2ff*JAV)0A`Z3GCXPc3T^!*af_pcsZi&3Eo! zLWJ1yhOw$T{+#)-uI)#n2y;pY573z4FrASp-l;?oT+P-A@%w)?BDKDUf6mX7(3bpZhf#MpefRzQNnABzNS4R4*O)WKE0}?KC0)$ zBbgjo+k1)k6Z#FHzV&Razu0jCiouFqC|=nDZ@ zlcRn0I!8kn!_GVet)b{!dRt`DnV;U3*_Cb$l?dcEK1W?im__OFZ90>VgJP62VxmIn z#5l;%oqFq(d|WG`eP=(#D>>(NhMT%M=+qX@FkMVPqr7hWEp}jn$^|HE4W4-1fZ9uw252D1XzOo%q-ja+|_j2O21U zKnw-oRmiAk@ySPW-4@BRSCyQmrF?J>>O=lu)bCvKBP))wJ6-hT&e=s0lXMxJa+GX~ zoND3kGe%f)NO)44Zi?d$cGAW;8ucU`{q`uEdk@+1ij&Gy-54}9)RzcP4oB6l|2mrK z{1c4HODx-?K-@+=-4Oo4luH&c8TW2U6-y8_gTn%ASm1*Vkdne+>~g_Ty^}-_b&00% zNim<|Xm7WzY+Wz{6G9Jl4)wUDW|I#iSL90O%>xEU^QP<+JYl1U^`kTLkKs=(MMra6 zk32?tV1Q`I9?omT**B7$pG0UO8D^N?01af|{6jf#{viY;BrG%{ECLi1H27z>5a1+4 z3}{SrEDAPJ6&MqjdP;UNRW)_jpo9i;v&5u=K5VM@BF3hTGqW7Z-^JYuIh_N)1Q+f6 zlaz??0i2Y0pdNvZI6%3FtnFqu3zN7{4drRE{|2Zlff8InwdfwlQbOC9>woZ|{@}^z zkwC3gKT_#f6+l)uRM?Zwdj1p1Cs&y-ebTfj8Rb!NW|b!fzcm%l)V1p(?FUPDdUFKp zo*oCuL5Wg$DPq2UqCJdzDdDRkd__@;;GS{+4yRduOy{CA(Y65vRQ-Cv;BT_w*^wY> z5TfowbL%1x)*B$k^e_#3kpe-F{9ut#2suxoVJf^QjHeLMxV-dQ_PN$~(!G%5+Bz~W zoHnCipl=S2P(zmEhdOYxRWn!Jr&3n!^ZtQnFS-(s3L_J|miLvuypyjU9S@ zon0V{KPD)a3gM|CA6-DSg2tWI@EToV${6Q3#yjSQ1RlD9+&e46?6rwpB|>ZhF1KTn z-um*0jA32-Vmh!zY$liShdpIXv@28W0?%(DimO;|l%4l}rPVh|9QTcE`DFOJHk(v% zqq=u_V|LS{x8I3XhHf2A3-Qx`aauWbDJVqtW9)HTkeSch3Hs@XNa2us73)<1hv+Y=2KKPu=Pkb_bE%fU z_)cJd-?CLY8-cmhfXN*-L(9`u%j}0`8)Kk&XAI97gz3&~MwDDbRH@`~GQl)Qctdo+ zFE}`J2TpkJF1mRGkfHw?CW5`f7gp2sF3Wh_|8gsq~ME z`{P|PR+-*;%^5KOa8HWho@9S7Aym3`4!8yoRqCsuN-S=DS;W8yulKLs!YcGr_SWNo zv4FbLwK5M!FBxHJ+H$DT&bC@px~l1gDAY+)%VVt$+FgBU6|B_BGDzaC3*IqYtRueN zamjP|F#PLCCv?IUldAZi9rQ_C+FX%_ zHAYn*CA!RvVo&PqXDX+a;~-uI?Q>7b&LGhThgS9Y*9~?%9H`-B-CdVikyOD9M^-^P zBvoN`p4FAzQ5{`jX(2*OA&`^9=l1% zhe;VhYip~KFP^#`=>5vD*=0-LXrYN2zQA3ymk!!F2am~J%LqHwzOc8-@k3iG{46wq z#N-HVt{QXNxO?5DsMB(DQMCSzU|^{{rypdwpeMaD?V;5%Zl~k-}I=izk8W! zs0#euZ*R*(C|vavW$5$p3RlmFO7WGdjZqNfne<(X-B!mcDJE8;qBu;EAlAKjoRSFR z4M0^smzoL#ga6a|A%LvmcBqRpC6dk7?LvQP%IRGDAU?!mOqmrt4kB}=>d!V0swAq? z<&&f7m;x0wq|~-qO}~2Do_73VjPzc)U!&~34My$(WSJ2Q&{obo@y%bP*CC-GW$Mr0 z0Iqj70cu6lWu9L(bN02E4l1X)BkpRwp{J{8nZ{U}&3e3R<8y#}Um3giadF~u99pet z{C}Kr;<(U0l1QYSue+loVm2sBfJ{1B%v>-@4lah;#a!mK8(s)^Qp^T0e()|gd^R(n zh`_KzJQ=9Q9dK-kO&#)oo}o^UPyCc9HElQaRfDV{IqzwG^bN3!r+ZbH5dT~Or2bIx z`FBST3Yo^M1W?HLHV+27{tVmkdIaQu+fy@;efBw-h<=VQe5gb}w9R4@=Hr2$_Q;u> zZt*A~Qj((cwDb7y0ul%9(Q)q~$u3huFJLn*O*cxX3gr;!s}B!VLE88B&OIY1>S|Al z$)BDHF{XyvzXlnYi#=S685_Fo%e;UJG^V8?jiXtb`|phTGzBmi17Z)E6FHF9-O4OH z`sByP>_y?F$(Q8JgkMjj`b{1HVW9pj!a-2YA)K+z%_&M@`p?PldSVf5?2u-P0Pjid z$7;V2({Q1_`Z)^X?1Z1NWvK{->Ew|7_rm5D_L)FtgI^ zx}Y{uYfWdzKa5WHMcJ+~po?<17lYNz7$MI=q#kToXSh;!vmKs1tHL(=vdHgFe(7dA zM&o9^5BWWgmiX2^l>x(oLc`J+M)sV~@?CgJR2`hN-XgoF-RAyi#irN0ZOMa!tue)u z_+&o;{`bqCHG^jLSmbu0iIUD}0)#2M2~Jj$jD0(6@U@A9{G9phO%tM}O)fQ)-*N$n z8FkZ_(vxvrRX9SwxIL?7aw)p`{N4pSVcYkcC*&NtOOKplFpkM4fc4oy7R&W1aTlJGGxv#Mj6V;5~l%HqOEn_0Hw6Abxy;_1uubE`e;GsMsD%Mc&5)-n>d^K5Z8ctmQ z^p)lD23YmiHA+4@);9)dLvSp~>@2%-@RbTEg?!3P)7^`4Yi&x~lY-Ffbjj{=^rSC+ z$9Wy(HkkdB3z0J)`R08GTe-5d=qNp|g`@kRMWjNAvjtbzWvS@=ijyr5=ZrZ#EA9&L z<;6+4QeG;UJ&ISzZxAO9uVuhNoow8IER6kx#=#^)PM`Jp=qQ_Pody8F1vo;<>5T2g zt@(cm-PBSyT;v8~(l5ZZs{Z0A)w-X}>K!PZa^Q~nXPX3no}qbEjOj;0C*3ELc!5_J z49;q1xxB>qarsG*5q?o<1v0P)F+>syCIip(mtH!eE9C$NcJS(zQtRA)$~m@G&9UQk zs|1N<-N-e7L-quzzM5Z(D^^BD#MtM&Q@L^IESi6Qd8st9^U*idr|}{3j_OLCU+mKszL2ERitLDhQku&pL9IMkYu!O8O{v*k4g|m?R*%OVN4>k%n>lNy66l zLTBy+I~b&r8q+Tk?`6SBcTGP8n-(mfB>Qw_7y(3B;ve znKB5)yiuSV%nB@5l^0d1P6?fSM7qHF3jPrs0vc=7nSL4HDt+5JLhV%0r^}PEMDAfN+a#3C6jXg|nOs%p{vB~77gwI7BM{%VAVadG*9=b(X(;u}l5Tz=Jo@|w0DlMF4XY2eq?Rnlq%>6|r?9Gkwplana)j;o z@4Ni9?r=Nre$I7kQ!6zUTMKC7#B`WlDrTtYOeSMO*tyR1Xv4MALw?IvOS&ZFv6Xp< zDf||J9XoC3GO&Ih%K6C-{v6)@N!fo7(r!-1(66fx%zlGD;QUgGxs%zKGO3s+QqX#) zpwZ0k_)4<%6_95is2Ol|i?Z8~qJ00Y*|_=Q2t%=vR>e0Lww>$^u=NJGG9at)N?*Ba zyQt}O#I_H*9zEmkFIwDxjPbtSIwv8ztKUEG@|p0z8-*L}Ew)SuN329J^$DzvJ4S0y zZ2wR!d?I^PZS&#Yupp5RvnTLGJy40tFny^sNh-I89;p90!EOm_&-Q8SxFu|C361En zmI-zr?)4)<(hd_Avs5OZ;Yqxy*-nGg!}~%9U-q1%{-C)(rs$t}A!Rw%?5$7Ny<%eh zWJHa_H|-?}C2TzveTMC_5QssF@yYx8Y*Zz8okJQb&?Jp~GcZ$Z{L2u(-b^Ib6aW>$ zSWi5Rp-9Sy&7L3+87M=&3}9ryrE#rb|0No@BO%|Om@)9xcznu% z%@9L+4OeYM9b=?3M;vV^A6<@Am7R#mAg~^1Hy{P4>x^mP|LW^I!U6!Ao#=e*y0{(Qf3 zCD&Y&yX@Js_F8N9lwRmY`CF4Kbbjc--}LyxL2YJ~1Or|iYlue7bfFhglZ)ju`P0ES zsj>UH*1gI%W45o~^43{fTi)AO{CEFDh5#{Xh5A(O^<_=B588y%!cOA!-l zbJ?>L?K@v0YgUG~rxG)&(Y$(CU&2q}twGt*rtB5sXk8-tJ==CVSJC$ymT_DT>(s2z z%?voF9pYW}k)^ z1*cx%Ov}el*l$uM0|?2zq8%>-lJw)kcQ3Kb=whO{EbFL z$8L={!aIFNQd@7>9uPo3D;Lx8a2UGcDxtBfhyVw&ZkRC4=?`A-syx# zYR|AO3E*-0d|i|C z`-7-KZjJ_97ljBary}`bUw0bD?xvB6!ltAb!-)PQ_Pv^{)frF<`u&l*#zmSy(>Unwu!*ADEYhke{Y`ej)l$PVFimT$8g4W*vWq%B6XR%lHf#A6We>3O4DCTCbl$Xsl2Lmq=$9&icO7Qau&Fyz*lB~U8$*GtP_g)>p z2<|+v#AA(04{-1eb1`gXLb_SpV&Zm!-oTJ?`XSdo82y5x?|6XpXv+g$QwuLy5&3q` zQs8!%ZWS*5aE>03CgBKJB(loYI^8-1?T#-CPJX(edSQ~aC8tOcXh_@(Z-C2Z2Uj8l z^pd5Tf5y!roE_{M!OwJ?KYxqvliIxIYH_+?A^PTiuV2HA+?*o1#>tZFmv7j&rSo4T zU%an5{zV%+GYeWg2)2pFWfa2HZw+GIoExd`x&STb{n}gw1c7ghjrKN~VsD;1C5lTU zVfSzxLiGu0;QP^}>*}JeC2U2@6@)|!=@cd@*MZ9hN+jFPge5K@BQL^Mu__(j6ve0d zR*k;_;1AoGQnba#Jb@8v=!thIq{f@He2kkBw4>pR1tE|jO}S8kr;$p);2!aC^=!J$ zq$`VJ<%5UK(Tz!}vGcII_iv61Qaz^k2mWtlQi$m%BKD_mL7lx$Uv{@5Of^J_5gDo` z=MnmKHPV5%q2FpS0mmg_iesn$iBi3xj!5!Gt{K(Ut2M!5uQ0ES2^pnEt?pjV>WI*` zSoMy>zy@=%?+H5tBEU|E5%u}DbQ8l?gaymafO@U?>KzijP@*>X?Lh>5?;jF&_{DUXcwER`Litzi+Z%|z=H7yc%u=kz8g~^cClVT(% z0RrJj`M9~~hU-bIwb!v?Wv+2M>`j$#esL1cR*|E7~y_0`(9cIWcO$jCg{M^HMmF;?9s|&flQS(E{8=$L}`R1rm z+e#T*15NXOVAm&b3VWe`6abUF+^gt!6ps^q6de*^Xoc9!lBL5maP8!{I`hi zp0ft04P&VTB3%Xn<)wU;dOv&Vy(Uf~eTY*_20%}-Ta{)gtIdpFv|XNMXAvrw_gV;A z5e=5q0=uGEzKEc%R zZva^~mTmyYL4ze*LtIz!xWmg^B;%C)JxsaSR2}<%f3VWejt*$~pS$S?uNlp5 zOFp2Tb^3`Z@uiY=rx9hEPBxj zd>>1Vw`6)PAg=-vA7q9&TF$U|Ychmxj`oZE_$=dEvi2seyUu5f;GA=IV?Eip!9l*a zch^6I2qc8-Xl?iJg{XB3=Am9Qv%R6OG2R4L{Y<_MK?8GikMX1NdoKCGYofWDn8W(& z_%GCguQRjX6(ojG2Sf@M*O=_aqXfCj7gbD1OGQ!~Voa7RJ2iiJsOm%C76$7ssO?cb zcg8zZ(1@)&IlO zi#_o`Je0bRrvQ;#p>!D}qewp!!NXA-$GH41Re=@5ti&V{kAg#^f6CFPFQ&qDTxU2p zA1>*m(k#2N2BMrrZO4~;KU1#Xt(ud1lhoTR+FaYUNi#w%SnU16>)V9Pcw-1L4J{$n z7v*0Yf1pi@VbmrIx1}Eq3_aLm&-l&;V@u98^GhS+>V2y=y02vpg;Y_PUhE(-JjN<~ zSqVkRCFDR*@5J*Gk{xL?DPIH5affPT*KS(ipO)%WF5`O?JiIJvAe>GGG0-Zj%^pYv zcmNcxeKN#4$>eXI5XEEJPN>}Om~aGz<#u+In`q2hi07fGDiTCS`Z@@Da;MjC$n<8d zPle~3{G7^oP0g_^?%{AiTAZKrQAVr+?Ya<8&jgu`hv+SI1VF3`d)}fyHK`f2(Pgf8 zp{LlY2|9qQd0xD60LrvL0v+C)F{jkbPkF29%C<`%)Ic{WYd!oA$W(o*(FrI787?7~ z_Ki{fk`X0UpQPZ(u3HyqgbbG|z++^lZLa~425=CcVs8uKboCP|9&BXHr>WiPbz3F! z-{&pPsh>O9?YPZT6^b6i6o}Wrl0G1fDOzn%kj6;q+Bw5@murRX>2A9M-P{1r3i|elxUyQP0Z{M~6x*8>KO|;+&%&q0Kn{}$kRia)c@Y01eXJT4 zl+dekhVt|442~t9+`Q+S{oLURjrRQhg}*f??M&;yCrt%MnFl7$cPfwuq+j|lS=8_| zbLh|bGRMd({kpPq!`JvfPhX_=vtOPB>9~r3Y8CSUBCfD~TNC zkwGN_i4Ix_Ig4sh$48-yf3g_r$*Wn*$ZEp@*_vy2wB_mA?$Mtoa7Buc7xvza-?VNS zQ1Nx?w!7UGmKmoH7q(TXRuVsy!l?kB z#0wFU^rLUavrGt%g+mvU{i$wR+KSVG%NU5Mh)+vx(ISqNwohiTQsSEt>wFhRF`6;w zn0a+i0>rf`*%$*C>s=|)#i3xDVl`F$07of+k?(y^0cB$)mhMsDWo2@4{WHk!ND8?L zgVhJ9z!X~I8UDBLl@R%}R5$YxoU7@Z^MB&bCl=#o1cR zi6e^e^3#ITa$A#rCO<)uB;3&YnNlk=%_}>=IB1*XJmS;mrgCYFyhnJmd&>cxO}&Qd z>u@R$atNAzv#vKg|EgQa$FVxVdYKZm`ZAw+;(1zh#uwCigaIfLY+@elA^j8SkPMO% zZ`(Uz8@1cW=s0E;e+(Rz=!`=i43yIY(7KwY&l0|lF6kQK+?lzbnyet0*=1yF%fHuG zuv?(w9}$Vai!_FR<85`+{qgmKDL#7&2Y#V=bJn!e7a8!fBP;|XAFgRX>uNDe`>sEdU^^0+=AjdOa@nt) zbFcK1O}oJ2Tadnx75vzw2CcRFkSAfKjKwh-F~5pJBJ=>m)Iy}I3#cbyo5kN#iJ>jC zv_P!%0ln8T?Je};_9a1Cf(FS&){_b@Y$6)RLs?_hfK4S~9By99X@v(UG z&}!B@2l@gKqodBQY^rmj*s^AmcJAvZICK$gAqN=vG*FRh}PH49>#Tw~w-#q4B zSqa;7i9|~{sGH^TFjN?WOgLv5-ynC$%ztXc5cS=(^8CK zu*kP>P^@`u6%m%l@}FPY7B?$Ioc9&_T;KM#W&Q7o2k46OIxQUlQ8Z0 zh`tvy2aat7mm0++EYr$mpVTJ0h*PY zX)XcXT@@g^xUE^)dcRD5&YzX4;$i?@l7ydOxW57jlVb+5%00q!B-j5Loh?1^HT%y| zR%SEX?@~YF23a)l19l{Ge_gzCq~>S%Yv*dR$*2Y@OCc?nTJnx zt9J$wXYSqg9JR`6DYFvH@*d%8EmzhP!qcx#-*V*lK5F8Zl>ebI-9RhGF{tNw0Pz*xH$hcxvP^0dKgH1Mjb%k;us>tM!)wXjdh%uyQAm%xx~{QFtd z5&`_Y$c-_}SkdtMw*VCeuJ^U-^2jsK+E!TZ)Akd*0((Cp0dWQ-9n0qP=gVngk>i=- zR<4l5czrijYhO-CG^uTRoYfk?0{>CIM7Fwe`q227Wa*b>cRhs+fE-yYN$K6WHoV-5 z!T;fZm@oJmA!p=7%fSAAaHce@;TG-C<*w9O$6BOV zGq3=lD#YwPEXTI_6QKliR*&1o@H5KT^*pT%p@Yv@3-}cC8?%KMk~2LD3%TB{vcQJd zuI#N)gVMa|0JQLr*~Di>ZjN!WdjeLK7f8Vj^)d1KhWRPB8PeZ?U1tcH!8_L8z0wh_KXjQX$FY^deb-ag&hH7f52>~V3j|xkg8tGBldBgb ze9_@TtKL>XQ>X$n-Pm26VN9~xb*xO(!>^xEDZ|=(^tJXp9EW6l-;=k$0W3Wm zx97u9wtpngzMOfPQ$!O9j#s#Y%KuZ-F?ouX_@|krL|@m?ha4Fn8(~&Qcsh^5;UY{oR0%H!WaP`r&i=eLp{z=vkob+#$(# zY7B>hDZ%e+)#5X#J!@Z63+{!LP5osEb*}vXJ*||`*3%Ck0c>)QjTUr|KCnG`=JNG} z^B>{aDn{qlk4vp|{Xwce*t=jhaB{=zc_A9(eUwvmip+S{Q}cs*+nPU%RwFas@M7a1 zUHMf8x>TI*Ch=mp48hQ!JZU9vYrZ-Jqghkc3uqf6JzP`A&9~}?rS8a+W2{AMFJHm8 zy9;lBE3LzTYz9y~do_N>xMAO~_}wQYd%6J?1eAv;+ls)Ovx zIm}Q{qASROFR(ev<>N0|Z^GA%Ny^HSEyFtu?#|-$0 zxcwLA3jSU6y6-5CoR-(`2hACb>Mz(=f1=>V?|;Uvs_U0A($XVj-BOOEBx%>yydP2c zN>HhlYj6iiei}Yy=Zf^E+NiYHCM})BsnymP7)Q}A3XFkm6{zVeh^BQUmlo?d98jYg za9%~50iy$wL<^R`@rduWCb#o<7+Te-;SFiGFr}A0jAJ6HT$CBdp$&bMe-6gnT3B9qwTz*u2 zuH+9|B*_$a&kpl87>XTn8dO5U2V+omfMtny68C;Q7oZi}zS6+ByWQekFgHjY>P2u6 z8))&M?(ES!Qjx&-=zasrx%Y)+3GlkS|4mU{Paa&nQcZCsYaZ@?z2>J$SB#zdY3`kk zH5=Wr!(}>_ui_@X8NyH1gKlqV7^o`IjQF#3zl88t`}A@y2Tb0JMfN@y7#WdeC{!Ws zVL8QX5qJ<)Wf+$4`abt`rd|CHAmP_#2^6|P;`_E7TYIN(n{V-km?bBhrzi9C_B9*r zu@7QA3qz8-E7?Xr-Z`Jt3{`sDJ}z`EkzxQ`RVW>QxFt>lv2?>Jt|vljTKh>&4D}U! z5(#%CxQ#r8N`i#;GDsXVol6dcMx21oEko)BD>!Nv4NC&~H(gWloxk4U6E4(>O~Jdy zvZpxQy|7{|7ACR~v3^4zYei$G0lnMW$@$CGjJQ_jwg`!UrjZ%%Lni#{c8hyS z53CTu$zy4)KCQmho{bc8uyLi*l5u)LL2E*Cw~llcu==uqH=qX=-lxd`C8L(rBc`zh z>c(Abd#Tes=aLL!8_5u99ZIF~D0_D#nHaILRnB?NlPRkWPigPBtqX8%bQTxfK zfD|UDp|Xb0&aXpd3@b~nX39yy4)ev+YI2ZxZ65}?F8J{zP$$Norg@GzzUe^!6;1Rt zp^;tYf-}By=Z#0S@|M@Y{ckBUL6dvFuGcse-yt}RrPx!u2A$^Oo49jPw5l&CMt-9X zzH^?#d|y(yHi9Ioy(t@Anq*j5t3~o_L?&TU1IMJ;vPZ|J*=yJxg0}HjF6G#=TGaex zU^7JQ@P#SuUO)uC4$Aj%x8d$@*drQx*{4r{X{D13UECzN zP}2h=I^!;=8)ZGm)*@906dD%3W=ca-a!{fMnbFy^+${1`SacuqIp|^CeO7#Xf*nl; z#X{%*GwFZBq-XVN0dJJ^i{@8C-}^p~k&ZsoT5EmV0cP(x_%wHhvnyIEA6H*0J>pbN>zKi))5g zXe@aNQa_`mc*`#t9n~>;((#PTt9Y#V+XwLh9GWmNvn*4zxgRRGPXGgAy2aYfsiF2L z(z5`H+q_jm(Nb?5NPJRnSxkwmiG#|sgyQ)8Mo5kaJ>UB~pBbC_cNYRgWHRRfR8CTPj84d5hlSsCy`s;7Y} z_SqxeJIeKOt3X??fa^Ds@z$KLOr3G^OKRPrcj{@AUCu5l;pAEIx3Vu8lG|xMBg}8r zDn)3}5_0g$M^y^;!tT!wsadB5a6D+k7A(bJEMRUz7rKwQGd?5kygV6pPiSBK5FS@# zLRa@knf8iUIoT$Mpglbqk2GEbgPN)Gys{qh%K-u%yXBWZ3 z)DU6?)i3^~L672pG9ye#cx|>`?;f4AsCD(0G0J&+)6pw>GTT-}nlT4vUuG{Mnqw+4BEB+UIaK z*P&Om7K_icV~r~YGu$_}f5pwL!@rUa6-;37+-Y-jlAqshR>>GkUUFX^*L|2TY56(VSZqQ?G#a0X)|r}kjDfv{~5 zfp#a)GeJ=G{{hJ<`@hWON1v#F`W3Z6r`T1%|NH^EBokguv=R^#7dU@E2sJmOS$?-Ji=wEt#T5sX1RhC#pv(Jm-S{b`4dUX%Lb$Id z(t%c%P&T}p?-`H-o4<`WVSc2JlLqeVu0jL4d%I*%ZNj)|sF&{c*do+7F3wX^XT0g% z#IB$GL=!vj9_y}QnR;#7K2?RtnVkJpvj)4>TxG}mH0RV9(}2b9I7)3iU8gw2RXMb*#(mTTB`&Sr{$K+c7$fL+vtV2 z)8anM-sma=+^p}`3kub%_0q>M)(q;oxa|=^Mi~#AS(fyHH0qk6$9Qc{TGY=kh8J>; z9nIsQ@4pFkcI?sE5ULaNW?4Y)vC2Oi9knavPLOB3dkAadOPAz(0AVSzj!hZ z!P*}N*F8gA#GceK7#_MNC}(F^V)YxNnle#wcP)jJets_}_wQEa?U9b&zaTjX`7Cq~ zqfNxE)qB4=8@X5^-Qok1?e2XNPXm2&fSVTg>JN2g`Fao^f>W+h* zseRAn^8(3t0xy}?d7eVtB@=VZvja%u(=};mhPR>yn-abbu3QTIp%7lOuqADn^KTc< zsXf%E{gk;FENruZ0yG%o`@Gf+Gt3#QMOD#x*glUQW3YPb{;f@B(5gY6AyJ`raNaC? zQsZ>il?S}}&h)090K_d7zP*KvJ^@AtEPbX6^r z&JICQV8rOuNOHg>zFUHE%&!M2+b1Yh$Vkr}RK2pHZI20u)IDxh zx)YB!3__MLMHp^>p3r-bW;@xmImcV<*Tkku;CSpA#qmd4|$kDq@Gb$(K|h_dGLd5jI0?r|^n_oN+6&)bBcU5<%*)SXFx zn0BNSXO+)*e@`GO^3F73GtJ%ui4P=6moH5Qvv2`KG+*7g?pdHoy6thd%tXYv`&F#? z^5=A~nos7)wXhuLuHHBuGrLB~!;&2@2*1hA-sgvNeu)d_gKw9=MC#H8HshFqM&Hf&D{UWOVXmv z??5(9NVt0#q1rKP^^As?o{&UE6UP~8NoH`K^>TkTftHe{LoM$wgwlzhS^*hPfh4OP z-^sM_2JwMqQafoQ*7faE+wjCAO4kptAT_Q!cI1!DbZ8lYkq6*^lTxk;R1!@WFxPu) z$zYJU!p%*qpiA(NaltChLiL2TW;s-Il|LpUIT+y~6hu|@QuKJB!2yPnVhr+UgepBZ zI#FGmNOW6Kpj-3UT>kPM~G-HJ7R+ zv|6ZgXVP%kv?KsGi{I*-icRL(OrU?tc@I1{e*J1^LXc1QC zfOpk;srlJ+&oMuJ(0P|Gc&Lywxgeo4O=nQF6ke7fR8h!JR0d?Hi~1Q|Vx-1ktZxli z1r&K({{6vNO|POt2*4T++EzPB_&U5~$qF1h!SD^c;7|2nE(7kF{9MQ&qD3xJ-R4bF zE(2(BnXxeg0N4SP3EI;0 zAS!d&8Sp&fUei;j4K6EEUwZ|luaG9wo5q_*eg$%`y58U@Npv|$%G`3G++FGj=;Zh J@$V6H{{wkWQyKsO literal 0 HcmV?d00001 From 6845fd9b6eafc9cd55a047f68d1f84f4d7265506 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 16:37:28 -0700 Subject: [PATCH 17/68] feat(library): Best Conversational AI Platforms With CRM and Helpdesk Integrations (#8639) Co-authored-by: Sim Pi Agent --- .../index.mdx | 263 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 30924 bytes 2 files changed, 263 insertions(+) create mode 100644 apps/sim/content/library/best-conversational-ai-platforms-crm-helpdesk/index.mdx create mode 100644 apps/sim/public/library/best-conversational-ai-platforms-crm-helpdesk/cover.jpg diff --git a/apps/sim/content/library/best-conversational-ai-platforms-crm-helpdesk/index.mdx b/apps/sim/content/library/best-conversational-ai-platforms-crm-helpdesk/index.mdx new file mode 100644 index 00000000000..716442e0f6e --- /dev/null +++ b/apps/sim/content/library/best-conversational-ai-platforms-crm-helpdesk/index.mdx @@ -0,0 +1,263 @@ +--- +slug: best-conversational-ai-platforms-crm-helpdesk +title: 'Best Conversational AI Platforms With CRM and Helpdesk Integrations' +description: 'Compare the best conversational AI platforms for CRM and helpdesk integrations, including Sim, n8n, Agentforce, Zendesk, Intercom, and Ada.' +date: 2026-10-05 +updated: 2026-10-05 +authors: + - andrew +readingTime: 12 +tags: [Conversational AI, AI Agents, Customer Support, CRM, Sim] +ogImage: /library/best-conversational-ai-platforms-crm-helpdesk/cover.jpg +draft: false +faq: + - q: "What is the best AI chatbot builder that integrates with CRM and helpdesk?" + a: "Sim is the best AI chatbot and agent-building option when conversations must trigger custom, multi-step work across CRM and helpdesk systems rather than only answer questions." + - q: "Which chatbot providers integrate with Zendesk or Salesforce?" + a: "Sim, n8n, Zapier, Kore.ai, Intercom, Ada, Zendesk, and Salesforce provide integration paths for Zendesk, Salesforce, or both through native products, connectors, APIs, or integration layers." + - q: "What are the best conversational AI platforms for customer service?" + a: "Sim is best for custom cross-system service agents, Zendesk and Intercom are best for teams centered on their respective helpdesks, Salesforce Agentforce is best for Salesforce-centered service, and Kore.ai is best for large contact centers." + - q: "What is the best conversational AI platform for a small business?" + a: "Zapier is the easiest conversational automation choice for many small businesses, while Sim is stronger for technical teams that need custom logic, model choice, or self-hosting." + - q: "What is the best conversational AI platform for an enterprise?" + a: "Salesforce Agentforce is best for Salesforce-centered enterprises, Kore.ai is best for enterprise contact centers, and Sim is best for enterprises prioritizing portable cross-system agent workflows." + - q: "What is the best conversational AI platform for Zendesk?" + a: "Zendesk is the best native choice for teams keeping their entire support operation in Zendesk, while Sim or n8n is better when Zendesk must participate in a larger cross-system workflow." + - q: "What is the best conversational AI platform for Salesforce?" + a: "Salesforce Agentforce is the best native option for organizations centered on Salesforce, while Sim is better when Salesforce is one of several independent systems the agent must coordinate." + - q: "Can conversational AI update a CRM after a customer conversation?" + a: "Sim can extract structured information from a conversation and use authenticated workflow steps to create or update CRM records, subject to the permissions and validation rules configured by the team." + - q: "Can conversational AI create and route helpdesk tickets?" + a: "Sim can classify a request, create or update a ticket through an authenticated API, and route it with explicit conditions based on urgency, topic, account, or approval status." + - q: "Can conversational AI use a knowledge base and take actions?" + a: "Sim can retrieve approved context before generating an answer and then execute separate workflow steps for ticket, CRM, notification, or approval actions." + - q: "How should conversational AI hand a customer to a human agent?" + a: "Sim should transfer the customer with the conversation summary, retrieved context, attempted actions, unresolved issue, and recommended next step rather than forcing the human agent to reconstruct the case." + - q: "Can Sim connect to Zendesk or Salesforce?" + a: "Sim can connect to Zendesk and Salesforce through authenticated API requests and workflow logic, allowing an agent to retrieve records and perform permitted actions." + - q: "Can Sim replace Zendesk?" + a: "Sim does not replace Zendesk as a complete helpdesk; Sim can orchestrate AI and operational workflows around Zendesk or another ticket system." + - q: "Can Sim replace Salesforce?" + a: "Sim does not replace Salesforce as a CRM; Sim can coordinate AI-driven work that reads from or writes to Salesforce alongside other systems." + - q: "Is Sim open source?" + a: "Sim’s core is Apache 2.0 open source, while code in apps/sim/ee is governed by the separate Sim Enterprise License at https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE and requires an active Enterprise subscription for production use." + - q: "Can Sim be self-hosted with local models?" + a: "Sim can be self-hosted with Ollama, vLLM, LM Studio, or LiteLLM-compatible endpoints without requiring Sim Enterprise solely for local-model support." + - q: "Does Sim support bring your own model keys?" + a: "Sim supports workspace bring-your-own-key credentials on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License, which is not an OSI-approved open-source license and restricts certain commercial hosting and embedding uses." + - q: "Is Sim better than n8n for conversational AI?" + a: "Sim is better for teams prioritizing a focused AI-agent workspace and maintainable model-driven workflows, while n8n is stronger for teams prioritizing a broad connector ecosystem and general backend automation." + - q: "Is Zapier or Sim better for a CRM chatbot?" + a: "Zapier is better for fast small-business setup across common SaaS applications, while Sim is better for custom multi-step agent logic, self-hosting, model choice, and human approval." + - q: "Is Intercom or Sim better for customer support AI?" + a: "Intercom is better for a packaged AI support experience inside Intercom, while Sim is better for vendor-neutral workflows spanning multiple helpdesks, CRMs, databases, and internal tools." + - q: "Is Salesforce Agentforce or Sim better for customer service?" + a: "Salesforce Agentforce is better when Salesforce owns the customer and service context, while Sim is better when the agent must coordinate Salesforce with several independent systems." + - q: "What is the difference between a chatbot and an AI agent?" + a: "Sim illustrates the difference because a chatbot primarily exchanges messages, while an AI agent can use those messages to retrieve context, make bounded decisions, and take actions through connected tools." + - q: "What is the best AI agent platform?" + a: "Sim is the leading platform in this CRM-and-helpdesk comparison, while the broader category is covered in Best AI Agent Platforms and Builders in 2026." + - q: "How do I evaluate CRM and helpdesk integrations for conversational AI?" + a: "Sim recommends testing exact objects, triggers, actions, authentication, permissions, error handling, human escalation, and audit requirements instead of relying on an integration logo or marketplace count." +--- + +## TL;DR + +Sim is the best overall conversational AI platform in this comparison for teams that need conversations to trigger maintainable, multi-step work across CRM, helpdesk, knowledge, and approval systems. + +Conversational AI products differ in an important way: some primarily answer questions, while others can retrieve records, route tickets, update a CRM, request human approval, and continue the workflow. This guide compares both dedicated customer-service platforms and action-oriented agent platforms. + +As of October 2026, the ranking evaluates Sim, n8n, Salesforce Agentforce, Kore.ai, Zendesk, Zapier, Intercom, and Ada using vendor documentation and five weighted criteria. It does not use integration counts or feature claims from third-party directories. + +## What are the best conversational AI platforms with CRM and helpdesk integrations? + +Sim ranks first overall for conversational AI connected to CRM and helpdesk work, while n8n is the strongest connector-heavy incumbent and Salesforce Agentforce is the strongest choice for teams standardized on Salesforce. + +| Rank | Platform | Best for | CRM and helpdesk coverage | Action model | Weighted score | +|---:|---|---|---|---|---:| +| 1 | Sim | Custom conversational agents that take multi-step action | Authenticated APIs, webhooks, integration blocks, and workflow logic | Visual, model-driven workflows with conditions and human approval | 94/100 | +| 2 | [n8n](https://n8n.io/integrations/salesforce/and/zendesk/) | Connector-heavy backend automation | Salesforce, Zendesk, other app nodes, and HTTP APIs | Node-based workflows with AI and deterministic steps | 91/100 | +| 3 | [Salesforce Agentforce](https://www.salesforce.com/platform/agentforce-platform/) | Salesforce-centered service and CRM operations | Native Salesforce data and actions; external systems through the Salesforce integration stack | Agents grounded in Salesforce data and actions | 86/100 | +| 4 | [Kore.ai](https://docs.kore.ai/agent-platform) | Large enterprise contact-center deployments | Enterprise integrations and APIs | Conversational and multi-agent orchestration | 84/100 | +| 5 | [Zendesk](https://www.zendesk.com/service/ai/) | Support teams already using Zendesk | Native Zendesk service context | AI agents and service workflows inside Zendesk | 81/100 | +| 6 | [Zapier](https://zapier.com/ai/chatbot) | Small businesses connecting SaaS applications | App connections, including CRM and helpdesk applications | Chat experiences backed by Zaps and app actions | 79/100 | +| 7 | [Intercom](https://www.intercom.com/pricing) | Digital support teams using Intercom | Native Intercom service context with external integrations | Fin AI Agent with support handoff and actions | 75/100 | +| 8 | [Ada](https://www.ada.cx/platform/) | No-code customer-service automation | Prebuilt integrations and APIs for service systems | Automated service conversations and handoff | 72/100 | + +The right choice depends on the system that must remain authoritative. Zendesk and Intercom are strongest when their own helpdesk is the center of support, Salesforce Agentforce is strongest when Salesforce is the center of customer data, and Sim or n8n is stronger when a conversation must coordinate work across several independent systems. + +## How were the best conversational AI platforms ranked? + +Sim and the seven competing platforms were ranked with a reproducible 100-point model that prioritizes integration coverage and the ability to take reliable action after a conversation. + +Each platform received a score from 1 to 5 for every criterion. The weighted contribution is the criterion weight multiplied by the score and divided by 5; the displayed total is the sum of those contributions. + +| Criterion | Weight | What the score measures | +|---|---:|---| +| CRM and helpdesk coverage | 30% | Direct integrations, authenticated API access, and flexibility across systems | +| Action and orchestration depth | 25% | Multi-step actions, branching, retrieval, ticket updates, CRM writes, and tool use | +| Deployment and control | 20% | Self-hosting, deployment flexibility, model choice, and control over execution | +| Maintainability | 15% | Visual clarity, reusable logic, testing, debugging, and operational upkeep | +| Handoff and governance | 10% | Human escalation, approvals, permissions, and enterprise controls | + +| Platform | Coverage 30% | Actions 25% | Deployment 20% | Maintainability 15% | Handoff 10% | Total | +|---|---:|---:|---:|---:|---:|---:| +| Sim | 4 | 5 | 5 | 5 | 5 | 94 | +| n8n | 5 | 5 | 4 | 4 | 4 | 91 | +| Salesforce Agentforce | 5 | 5 | 3 | 3 | 5 | 86 | +| Kore.ai | 4 | 5 | 4 | 3 | 5 | 84 | +| Zendesk | 4 | 4 | 3 | 5 | 5 | 81 | +| Zapier | 5 | 4 | 2 | 5 | 3 | 79 | +| Intercom | 3 | 4 | 3 | 5 | 5 | 75 | +| Ada | 3 | 4 | 3 | 4 | 5 | 72 | + +These are editorial scores rather than vendor benchmarks. A team whose entire service operation already lives in Zendesk, Intercom, or Salesforce should give more weight to the corresponding native platform than the general ranking does. + +## What key facts should buyers know about each platform? + +Sim, n8n, Salesforce Agentforce, Kore.ai, Zendesk, Zapier, Intercom, and Ada differ substantially in licensing, deployment, and billing units. + +- As of October 2026, Sim’s core is licensed under Apache 2.0 and can be self-hosted, while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); Sim Cloud combines plan access with usage-based model costs, and hosted model keys carry a multiplier of about 1.1 times provider cost according to the [Sim cost documentation](https://docs.sim.ai/platform/costs). +- As of October 2026, n8n is source-available under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), not an OSI-approved open-source license; n8n supports self-hosting, and its cloud plans primarily meter [workflow executions](https://n8n.io/pricing/). +- As of October 2026, Salesforce Agentforce is proprietary and delivered through Salesforce cloud products; Salesforce offers conversation, user-license, and Flex Credit purchasing models on its [Agentforce pricing page](https://www.salesforce.com/agentforce/pricing/). +- As of October 2026, Kore.ai is proprietary, and its [Agent Platform documentation](https://docs.kore.ai/agent-platform) describes enterprise agent deployment and orchestration. +- As of October 2026, Zendesk is proprietary SaaS, and its AI agent usage is tied to automated resolutions and plan allowances described in the [Zendesk AI agent documentation](https://support.zendesk.com/hc/en-us/articles/6970583409690-About-AI-agents). +- As of October 2026, Zapier is proprietary SaaS, and its automation and product limits are described on the [Zapier pricing page](https://zapier.com/pricing). +- As of October 2026, Intercom is proprietary SaaS, and Fin is commercially measured by outcomes under the terms on the [Intercom pricing page](https://www.intercom.com/pricing). +- As of October 2026, Ada is proprietary SaaS, with commercial terms offered through [Ada’s pricing page](https://www.ada.cx/pricing/). + +## Why is Sim the best conversational AI platform for CRM and helpdesk workflows? + +Sim is the best overall option when a customer conversation must become a transparent, maintainable workflow spanning several business systems. + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Its visual builder can combine model calls, retrieval, API requests, conditions, ticket operations, CRM updates, and human checkpoints instead of treating the conversation as an isolated chatbot session. + +A typical support workflow in Sim can: + +1. Receive a customer message through an application, webhook, or deployed interface. +2. Retrieve account, ticket, and approved knowledge context from connected systems. +3. Classify the request and decide whether the agent may answer or take action. +4. Create or update the appropriate CRM or helpdesk record through an authenticated API. +5. Pause with a Human in the Loop step when a person must supply or approve information. +6. Route the submitted approval field through a downstream Condition. +7. Respond to the customer and record the outcome for later analysis. + +Sim’s Human in the Loop block pauses a run and resumes it with submitted form fields; an approval or rejection is a field, so a downstream Condition must inspect it before the workflow proceeds. Sim’s Guardrails block similarly reports whether a check passed or failed, and a downstream Condition is required when the result should stop or redirect execution. + +Sim also supports local models on any [self-hosted deployment](https://docs.sim.ai/platform/self-hosting) through Ollama, vLLM, LM Studio, or LiteLLM-compatible endpoints. Local models are a self-hosting capability rather than an Enterprise requirement. Workspace bring-your-own-key credentials work on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise, as detailed in the [Sim cost documentation](https://docs.sim.ai/platform/costs). + +The main tradeoff is specialization: Sim is an orchestration workspace, not a packaged contact-center suite. Teams that want a turnkey helpdesk, telephony stack, workforce-management system, or preconfigured support analytics layer may prefer Zendesk, Intercom, Salesforce, or Kore.ai and use Sim only for cross-system agent workflows. + +## Why is n8n a strong conversational AI integration platform? + +n8n is the strongest incumbent for teams that prioritize a large connector ecosystem and backend workflow automation around conversational interfaces. + +As of October 2026, n8n documents dedicated integrations for [Salesforce](https://n8n.io/integrations/salesforce/) and [Zendesk](https://n8n.io/integrations/zendesk/), alongside HTTP requests and other application nodes. That makes it practical for flows that receive a message, retrieve records, invoke an AI model, and update operational systems. + +n8n is particularly compelling for technical teams already operating node-based automations. It scores slightly below Sim here because Sim provides a more focused workspace for building and managing AI agents, while n8n’s broader automation model can require more assembly for conversational-agent behavior. + +n8n is self-hostable but should not be described as OSI-approved open source. Its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) is source-available and restricts certain commercial hosting and embedding uses. + +## Why is Salesforce Agentforce best for Salesforce-centered customer service? + +Salesforce Agentforce is the best choice when Salesforce is already the authoritative CRM and service platform. + +As of October 2026, [Salesforce Agentforce](https://www.salesforce.com/agentforce/) combines agents with Salesforce data, actions, permissions, and service processes. This native context reduces integration work when customer identity, case history, knowledge, entitlements, and agent actions already live in Salesforce. + +Agentforce is less neutral than Sim or n8n when a company must coordinate several independent systems. Its strongest advantage is not general-purpose portability; it is deep alignment with the Salesforce platform and governance model. + +## Why is Kore.ai best for enterprise contact centers? + +Kore.ai is the strongest option in this list for large organizations seeking a dedicated enterprise conversational and contact-center platform. + +As of October 2026, the [Kore.ai Agent Platform](https://docs.kore.ai/agent-platform) emphasizes enterprise agents, orchestration, governance, and deployment. It is a better fit than a general workflow tool when omnichannel contact-center requirements and formal enterprise rollout are the primary buying criteria. + +Kore.ai’s tradeoff is complexity. Teams seeking a lightweight visual environment for a focused CRM or ticket workflow may find Sim, n8n, Zapier, or a helpdesk-native AI product easier to adopt. + +## Why is Zendesk best for support teams already using Zendesk? + +Zendesk is the best helpdesk-native choice for teams whose tickets, users, knowledge, and support operations already live in Zendesk. + +As of October 2026, [Zendesk AI](https://www.zendesk.com/service/ai/) provides AI agents and service capabilities inside the Zendesk environment. Keeping conversational automation close to the ticket system reduces context synchronization and gives human agents a unified operational view. + +Zendesk is less suitable as a neutral orchestration layer across unrelated systems. Teams that need one conversation to coordinate several CRMs, databases, internal tools, and approval paths may pair Zendesk with Sim or n8n. + +## Why is Zapier best for small-business conversational automation? + +Zapier is the best small-business option when rapid SaaS connectivity matters more than self-hosting or deep agent orchestration. + +As of October 2026, [Zapier Chatbots](https://zapier.com/ai/chatbot) can connect conversational experiences to Zapier’s application and automation ecosystem. This makes Zapier approachable for teams that want a customer-facing chat experience to trigger common CRM or helpdesk actions without operating infrastructure. + +Zapier’s tradeoffs are limited deployment control and a [task-based automation model](https://zapier.com/pricing) that can become costly or difficult to reason about in long, high-volume conversations. It remains an important incumbent because its app catalog and ease of use often define buyer expectations for integrations. + +## Why is Intercom best for digital support teams using Intercom? + +Intercom is the best dedicated option for digital support teams that want an AI agent embedded in the Intercom customer-service environment. + +As of October 2026, Fin by Intercom uses [support content](https://www.intercom.com/help/en/articles/7837514-add-your-content-for-fin-ai-agent) to answer questions and supports [human escalation](https://www.intercom.com/help/en/articles/12396892-manage-fin-ai-agent-s-escalation-guidance-and-rules) within Intercom’s service tooling. It offers a more packaged support experience than general orchestration platforms. + +Intercom is less flexible when a company wants the conversational layer to remain independent of its helpdesk. Sim or n8n is a stronger architectural center for workflows spanning multiple service and CRM vendors. + +## Why is Ada a strong no-code customer-service AI platform? + +Ada is a strong no-code choice for enterprises that want packaged customer-service automation rather than a general-purpose workflow environment. + +As of October 2026, [Ada](https://www.ada.cx/platform/) focuses on automated customer-service conversations, integrations, resolution, and human escalation. It suits organizations that want a specialized service platform with less custom workflow assembly. + +Ada ranks below the broader orchestration products because this comparison rewards cross-system action depth, self-hosting, and portability. Those weights may not matter to a buyer focused solely on managed customer-service automation. + +## Which conversational AI platform is best for small businesses, support teams, and enterprises? + +Sim is the best general choice for custom cross-system work, but Zapier, Zendesk, Salesforce Agentforce, and Kore.ai lead in narrower deployment contexts. + +| Buyer | Best fit | Why | +|---|---|---| +| Small business using many SaaS apps | [Zapier](https://zapier.com/ai/chatbot) | Fast setup and broad app connectivity | +| Small technical team building a custom agent | Sim | Visual agent workflows, API flexibility, self-hosting, and model choice | +| Support team centered on Zendesk | [Zendesk](https://www.zendesk.com/service/ai/) | Native access to tickets, users, knowledge, and support operations | +| Digital support team centered on Intercom | [Intercom](https://www.intercom.com/pricing) | Packaged AI support and human handoff within Intercom | +| Enterprise centered on Salesforce | [Salesforce Agentforce](https://www.salesforce.com/platform/agentforce-platform/) | Native Salesforce data, actions, permissions, and service context | +| Enterprise contact center | [Kore.ai](https://docs.kore.ai/agent-platform) | Dedicated enterprise conversational and contact-center capabilities | +| Technical automation team | [n8n](https://n8n.io/integrations/salesforce/and/zendesk/) | Broad connector coverage and flexible backend workflows | +| Enterprise seeking packaged no-code support automation | [Ada](https://www.ada.cx/platform/) | Specialized customer-service automation and escalation | + +## What is the difference between an AI chatbot builder and an agent platform that takes actions? + +Sim represents an action-oriented agent platform, while dedicated chatbot builders often prioritize conversation design, knowledge answers, channels, and support handoff. + +A chatbot builder is usually sufficient when the desired outcome is answering a question or collecting information. An action-oriented platform is more appropriate when the system must inspect a customer record, make a policy-aware decision, update a ticket, write to a CRM, request approval, notify another team, and preserve the result. + +The boundary is not absolute. Dedicated service products can take actions within their supported environments, while orchestration products can power conversational experiences. The meaningful buying question is whether the organization needs a packaged service application or a vendor-neutral orchestration layer. + +For a deeper explanation, see [AI Agent vs Chatbot: Understanding the Differences](https://www.sim.ai/library/ai-agent-vs-chatbot) and [Sim vs. Dedicated Chatbot Builders](https://www.sim.ai/library/sim-vs-dedicated-chatbot-builders). + +## How does Sim connect conversations to ticket routing, knowledge retrieval, and approvals? + +Sim connects conversations to operational work by turning each message into an explicit workflow of retrieval, routing, action, and human control. + +For ticket routing, Sim can extract structured fields such as topic, urgency, product, account, and requested outcome, then use conditions to select a queue or escalation path. For knowledge retrieval, the workflow can query an approved knowledge source through a connected service or API before generating an answer. For approvals, Human in the Loop pauses the run, collects the reviewer’s response, and passes it to a downstream Condition that decides what happens next. + +This explicit structure is easier to inspect than a single prompt that attempts to retrieve context, make a decision, and update a system at once. It also lets teams keep deterministic business rules around the probabilistic model steps. + +For related implementation guidance, see [Best AI Agents for Customer Support Ticket Triage and Routing](https://www.sim.ai/library/best-ai-agents-support-ticket-triage), [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows), and [What Is Retrieval-Augmented Generation (RAG)?](https://www.sim.ai/library/what-is-retrieval-augmented-generation). + +## What should buyers test before choosing a conversational AI platform? + +Sim and every competing platform should be tested against the buyer’s real ticket, CRM, knowledge, security, and escalation requirements before selection. + +Use a representative evaluation set rather than a polished demo. At minimum, test whether the platform can: + +- Identify the correct customer and account without exposing another customer’s data. +- Retrieve the correct policy or knowledge passage and cite its source internally. +- Distinguish requests it may resolve from requests requiring a person. +- Create, update, assign, and tag a ticket without duplicating it. +- Read and write the required CRM objects using least-privilege credentials. +- Preserve conversation and action history for debugging. +- Handle API failures, rate limits, missing fields, and conflicting records. +- Require human approval before refunds, credits, account changes, or other sensitive actions. +- Measure resolution quality rather than only containment or deflection. +- Transfer the customer to a person with the context already collected. + +A platform should not receive a high integration score merely because its marketplace contains an application logo. Buyers should validate the exact triggers, actions, object types, authentication method, pagination behavior, and error handling needed for production. diff --git a/apps/sim/public/library/best-conversational-ai-platforms-crm-helpdesk/cover.jpg b/apps/sim/public/library/best-conversational-ai-platforms-crm-helpdesk/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..86c550343273cb8c55cb433f8df308c351efcdc4 GIT binary patch literal 30924 zcmeFYbyQqkwl7+^L(t$Z!JSZ8aCa!&-3jgl5AN=+g%s`tcMVP;gkZs)KtghheBFKe zbien;==<(pcbrwQ*|pYQHrL!!esl7B_4fuq0)Y5eK|(@AL4|%W(9zJ)G4L?4pdUO6 zTmoF^4+RMo2@&*{lb)KIo|B)Qm7QNwSXfd^P3!I3^yfif{(lcL0Ki6uErD-i~2(XrC8wFQ64IAO6wp zKfXbDV@^`|ANP_nk(wz-m;r<50Pq}Jb~TJ@rdHRRJ7U(n7{$&a4;$Y4B*g6KhYqN} z=neJBJ@N>G{L9l)ZW6@JKpJHwdH9ghg8tDgnQJn z$|wAy(vBJhmc_Xqs%Zv=|7;q`VWD>EPQP+yl6+<+01Q!z*3daqN-8mM zI{XEp%>{;9Tg&B;$yyf3v@q*`Itg~@M7{yF&hx^gZ?DC`Yhlt7*_7ALh4bf#3Es#; z+yBv$c?C@gQ-c1ZF8}lJ|4`t6DDZz71&}g|9{-aq!R#IHx(yO6G8@}x0HXAMURtMY zL;G{OwuGUM9EgPSnGkEBRxc@ykt^+$@+^Dt>+Xg8_wl2*?RYHL!gF@qraxAv5qmjh(57U-LlxHoEB+#Ek~DaVMax@kspmHM_mkK6b52$@uD%!HuNrz32_-MQ^bwz?auXi` zTrl;xd#=;?k{Cq_=$3y- zlE)XBEyPD8;tOH(d%U-ipEX%;RGVZC<{&{~N{3fKcMYTPX%!!T++&%%N4y}dR*;>$ z$-nRexlyk*-|Sn-3|xgnDHhVTZ{@|E(k!;z+rJPSKP}Bpu!(WiqnvuZ?-62U@BWzW z&=+~>=xBBo9G(q45IC%zo=K3&mSQUfzyJ6|g)HJv)o0;!K9rH@+xsOf{Ser>6Rr~$ zQs2z8hxB?FGLD$y3GAHOT9MqTU7N-z51EL-zdhF0V;IKuABhC#!_;x{^=bKlj?Pe< z^HzGft{VE7b)yQyH$8&c(m$?H&AhC2v%#KdE1{I>g_rmgXY^e?q$hZtQ+?&?_8QoF zQ4d}y-p~PMnP>ssU^{w_{75VJGr7b~FpS?AT*CTLA!5GWpUf7{^M- z{faesTS79Du(;717>b6s5d6W1fifErw>Sbx}8v@4^_r+jWQ>oS4{-nCYH_5f_G+ZXAF}gyP@ku3Y@!qe0d6tp7 zmoYlKedBJUO5x5ujm}?XGVoiD=5ji%Q>e$BG^7ymUWrEJf#{VdyvviSevx>DWygtP^;9D4gG9#nI$W_a;iP z3G;29lihVmHHmK#4WXaH;sIl6cDaB$Cw1|(N^|Q5@Er59I40ZpMaI8zMLEZD8*&5zqJ^iukjijugE8BU!T4S6Vq|^>fP69S(%n<_`)v zIzE@#xT;~VC@bg}Ji=CRG4~mEcv>L=H}HiU91d%iqXoE`U%o2Y#eRUXk)*qXEk2xa zJN$r3pmYu=Kv%ztRlxMBkNLAPbpxd4EcO+9Oh}9XGZPsnvn;rpNzc7r>iU}lh(ZI_ zZ#EBU_PyB2Z4PH}fjYU1($a*g&I%d7dz^%`CiPaOzZz#p?utS>0<6qRi> z8!>*l&_pWSN@33@5?vsLQ}{jPUel-B%D&Hw;u9)rC5S8);DQsu2i3%@zC0n9mo#fF zZ_Zk3O$Q%r>)>zKdF7_d?#_pe}^1poxxBUa;=H?3nN-vS>0RSYU>|owJ55}x3M*aNv zV@|j>bRu5qCD0l@)cG12w3@? zybw9S&f>5)vCOTug3lb}#xK9gUFEc&l@igbU!eyx zp_I3^X=r?zEBWn(_0h18OovZ3dN@BMK8k`Wmx_HvS;nHck{4Y=+EeY=7b-*C^Ca!r zQAI9*pGoLlrNQ4`kI!=1qYZ^hI8=W)w5uMvdi4Xf=bJfdMvqM~NF-PRi8S zJx*Qj?v}SvRWMHYPFuAutylre6mJV3BSl}=Dg|VBrs6#JFEshfI2~60Bsqs^`_0Lg z?bWB0u{z7QliPLa0ebQ!Dg34s-LHOpIPg;jC%>fG5zx4kQt`tpD?@u^tH@8RaF8!v z;YrHDX{Ki;p#{^aF^=aL9!#RXQ&>m?wrzZ1*kkBixbd0E=y5A&uNYm89WSAynDc*_ zvmj??iq5F}V{9#<1rQ4?OoB1rN69zD2Na01xw18HBc=0gm}z+x@abFZ)TS=dZ(Wp} z!{wc#_RcX7Pv@f7ao^^lgKn4Z#b4HEeD0UIY2&#GK7AHQOh2DK;}3tEzMpTc0~5;* zbuf3mDD=fD5I|p>zYiyuWjPwrvoX^k`Z6OpDH1QBaZf_~??n0T1{!9%69(+(rw>y% zQOt_xy}wCsFZA-fTO6r##G+2^780KE8Xsv!&6zFijox3uAY+&(mjUi==4j-dqSP1t zP&JUq>8(GfF6wmclh4Lj&?!Ut;^T;XK2@{5Ka)N@!TE|U@{+lRoq|%@;xiJTJGru$ zzLYaoD9uaei~X8o%S78(y-63q!NT7FPk4grZ=~i0fpt$+#Yxor@i2`Y@)B&{O3`CF z!Ob&X&2+yi%A2f!U#|;qhpF!01*UF7PLtj8*=4^FcMe=#F;6njE>>;#&;_XqD#SRL znw^O#+aC($rrafT3!$!yG2a4L?u%OJaD^eR@!wE0KmY*z#zb(AR@xT0pI`tI2c%X1VjMR3v3)-Ts%H}f|rz3 z)Z9EEPA)MiRZSXN4pDIp6HE2CLG%(IppYRl6i9?Y2mA({)Sz!ICT}%F_QQrOIoV4w zPD#J{4G_0X|H$}7nn%h^aMthh&%t`(^LN*n8LxYP157+(E$&iIVK7bCrG2mK?d(DN zOmEk3rfu*);q6L>nfm%(CpY4`CQj@=H zZ}?7r)U~$MiHe_=6B&*pb}>i(&}lD)AJ*N^w?kPPc0h3RH$b?v3}F=>E;Uv~=C+_G z1st87omPph0JOA6J|4WiCK&_`<9W~Q3G?C|e|_2L${V)!Ezz#H**w2jwHV?`lS{-) zz|v9|$63|yN~9j@W!EpqOcGsPi{Mm<9h`@pV1(Y>(mYssS5 zgwcol?mhr+?U3JqSuTTA%^g)3wd*%o1q?r@{OyVH`4bN${ih4v$MgmS4xjw&D%-Hh zh{c)g0*Tx(X4Ftrxr1d_XePFk@j;kP*Gsz=WH%}rL%BWjA#=m%S+Wv{{ zw9c^mxc2#g;`(O>9X@|5K+}JA-+KHvfSZDNBwU#mI8VW?ty$Ow2y#t|(uf+U$V?uj zJ@=|LPg@2}vdE#ou{9JTcstDY=Czd=`ji%)qn{g7W?D+nAy}Flz`SY5A>XZ^e7hlR z)=N?Qi6Vq`L-J>VzTnTVzXFnXiz{$~P!qC#PCdNYH~*?LS2grrfM)-l0)Hu|!KCbo zHXVTqTT(qLN;%wJn25@krLKJchx?Cn33lD>oAMu*(c!;rSoC<#>eRnK_Cnf@@tl9d zusrh{!2cVNzk5F^hi60Y^5&X*_ctIbA*q~xJRMP&rJRyNL#Dl;|(vN1n znpsV4EZ(lT;&6>z`tjwEy%|qu=@g2y*(;}z`EqcYzM-%h=;fcKL<#SX9&g>(CkvBkqU&+Oi*lY=4Tc~!ANRD zOsCQtcZw&tD!y5Lr{9d3TU04epjM<$?6v#)8=z!WVal^0y&FF&L)%^!PJ0=fOA(~t zV=@(ez*KQ_)+yY2WR#$BWW2$b-QE}wEkW3bDnWE~W!~;Oi%YNKwu=2VRoslT#YD4p zz`hJ+HfZb2Eofd*l_+|^WCb%3M=NWh?uo?4n7CfYIFkB|U!M{YZb_}zMB~@KRgL|i zHZsSDgZ6i8&n%)Z{GBhq4rIFfXv=1IK|^@Z0X7F2vN0CP37Ip%ns|~mWU3aD)y`z{#Z^8Tgf1=-g% z@{@jz!eGnNC3i|39@%~>1DRdj1UH&5%UA7<4kvUy#?-d?MOkcuN&Ts&wTL!WX-Tz4 z@n%d+)a6YbO;iueT?!l?asfPANpgG(8o{<18!F6mi0&p|nym6qV|kje+wMP<#IAI7 z?-wL)toBUU*4wL&$hc7%#lSIi)Vul6^T!sCQEwqk44urV$HGy`-`KM?$u&k^Y72|P z$|qYh$57V0>npr+l99=^vVS?DwYE89#@pzIf)pcvB?E+iz@?kgV#$)`3Ok6Ee1(1~ zQoOa8-Vz&-+s37z2B8C1SN7_)KQ>RBOEVrGPndNlFiOKDmih8#=r(s?dE0A4P^=UL z(?(LYJ9+cMWN1pJIabK0eykzkOs17CyRjL_CK?i}iMQO}QRd(+uvop&T9Ya(GW;+L z(5F0c1y;})CRkpTUHL4FT#c^k12KF zC!{W$Hw?y=#z5SgL4RDQr~qdGMgp;Y1iL^ZR@EB~HdcnVbj_WlW)BE>pM#4b79)o6 z#n)(l#8`4&yQ9vEJLPQ~NF1Lm4MMk^cFdACLge8fu;ObA0gU%&bP4xE%U~`^@u#1B z!yl_w2Lz+Iv#?lKEULTP4c$?$cfDG9qK}U5o08_tYh2M;ob1M{D3HEp&e_VWr>Q2N z*;JDl+tX2^U{r$xQE28IGdmUirB5Z zeRN3Qm_tq(cpC^*Goptij>xR<+xN4{IHx}8;K)O7j{d1V9I$aisbFcm?kLuUA0 z7dpiKC=8oyCd<2{}euS5_LmavkWTmn6aV<%o zvf_miS)^wp5Toprzc0Tni1SV;xld^TJ2%%hwC^El&0Y4v)oZH4<<`GF=2lwH_xm8S?q?KZA0{FxV6Smj4nc2d)KCf>7;@uRcB^sQ=U?;7A5(4*w8U;1jx;-P)B?>&-}CkiF&AJ_H{>y`y{B~ z?-HHeC|mjH4!$trjjWcvhU=!*O4x! zor}MDFH4Xt#b@|na*k5S=fRfjRz7aJ`n6EevnA!!{fof4$UR|`KIFz-aY0&X1}guf zF_F?IpQXeAxyKAP$*;YV$;^{Y3vYP~Z<|%dZ{vfLosK1Mq?}H+V$a^B(Zm_`;}!bJ z;`AiMWEx9JN~K0PkUqt`=?h>nNoEnhC8Flc7E6r9B^MX2wT$eq^LK zp+Xm-~DN*RjS|j$Y14K5=}8-)QK=8vb0+Zf}W!tx z6Krz7UM{BLLu&eXZ*rUwW77rj;{nCR?4yvQmIu?*{`Zk(?78<9yre6<wWWAqW3KZ3wI3PeHe$amtea4LAqgxZL zn_6LGaaC=S5=|mAxDO)mu>vKcE(29HQ%Ae9HivG^%}jZc<^{E0oK4i!6wb+=&3&K? zzfOAXi1rn9jCKic_%Tq~=tO()wsT5!`|}j&2j4ro*bGx5?Ts$R*Mux1vvWdqYbgu;AmH|WnLyp?GBB($E={F0e8$v&wr z?bWpffn*5tc<^EpvOO0>Eek_C?HGfsGOxPGJSXIuqeb_VHA-j`$-1u3lBjeIQ5leh z#bQm7cChbzW+r$Iw4n@??Lb&c%WcHeFd$I3I*+B7X*8esECe|-a{Adh;R>@wEGYV# zhhGR*wpGr*Z?M%o24uy@@Y1g0bvOaM&O&iPe(@IXqV;&}KuZpoGGu~ihfH`Z6a9HTc#Nqb%r*Ws;+g0P7${so|!B%dQVakWS|_k^dj}WGYT+{`=pR( z+ZuN?(WU}>;MSJ;x_S_y=e8ck(XG+}ElY1SvAUEKA7pU9bBkFCwnxpyD!B1p2Ooic zOkc)b1o@aX%;yZYxG9iUuh3-5=bi~f zg+NIn=d;8wT|-tTc;aBOHbpzw^?3-vVtr=aJ)}<`sz7z>BbmK~XY9%Mo3d6iv z5$3rst#qz+r&37w#<{FFYhbA=cfz7^kt=hxs(0`|)34Bf78v-v__qD(Op2Y|2hSsbNHhHL&LVffNC$ zcOe`gjWZ`c=%c8XkNzNSL{|52T&qHuFr~E#SwN5mLrw|u^D*3AR_RBdP zPrWr|HC;SHE~Au`mS_R-1UH?uYr7A-Vbk(#AV&F*tZ%xyT-td1AZRgSj#+sS$Yv(p zg5R`8N#~#&@>;zUQt{r}!W?7~JmnucaG`&mUbWYi;aiwpyM3Ig{Iu4#i}+o#f4Bf7mV|hOmw3}?r=bpCdBqdQ;>*hXr^?k@Zi#5) zEN?o$oN5C~PULX{TGN6WupPom?~NL{5tciGQ9lAzuY<9h>jOt;eLsuxey0l&St9e! zvb2Rwu{5{Vcg9by#LZwWHnJq@n;SzjkQw4Eazun??_A>dJm+4{3?5rV#Qo<doo3NCLX#L4UHmaXa%b*d!iaB4BhjcEnU$r6oTG8zD?oX-8Tp3^;x5@FPG!RWy4toJb2_ z@uTguK#JUj_rlz+T~bB`lzg_( zWZpMvEvETVGi18Mi(5^jU@^j5*HMWu#URa{R>OeQg_Z`}PgXd1g}G{XD7%;pwVXyi zw@t--zJ0H$I&G|h$vFQayK?}B^<3D67?uRL4=s(LXJ*kr;87xp48O>cfE6<*IJc)c z&$9X2iDrmY8e*ssX)xFnN(+8TAG!2)Z1IQxUxY~z6Lu8hPsV=H){N~8yGWC2l@ehT zd=wi$$V2GykQ)gh=7|cCu%L;TTe?yS-HbX4wUPhi1fM+-d1=w|B7;9|=vb~%y#LiD zvF_qv?wX?++eu^N!Y6EVaeG02eyL)`*v3^_q^ft3IttB6^k~w>!!~uc75JkjKhRtA z9f!O_BbS*=Oua%9VS&M4egihED;GhaJD*9ZTaM!yJ@%^iMy~S#iURnzjA_{EN$oAu zernUM%pP1L7`_;m<0gqO7q1jKB0i`v+#DD$Y6ErY-H5-hIkI$!#o}TcKP`47nZ&fF zTEzzb8O3~En)k$&*u?X%}rv2xrekwK^Cdt)X!2dpm_2kU`0z2#)|a+1|id!ly2dG0Ne3H(qs z=}@Tc8E~e7@K?J;L%7LkFKq;V*Ph{$ICn7p+X$6E9g#~oBGfuaXHW8y<{>lY;E?zj zE}re85~5OyL+S7IxAwIsOpysuRMYY#4V#0fdRyYBXxmH2#K$f9-B&^!R!Q;OzX5+- zLd{W@?CR^RpF_*0u6pfocwSd|*h8+=)E$fN+OW;FiSl1L-fddE_;Je{gw5ZjQ`&nk zg#hyI(hckVCdG*DEvRGBdo78`bm`gX6EG9sf7ne{O_(>5-;J6}>KyB6l85~`W|2?E z^9_3p%$}!Ka*w%6zCnDT&H%xqEK8)wT8c zch{}x;r7|JAp0aW8?Gu36%z~L;YeIm(Fl_sa$%@n@UAktva+h0wATQZgFEtC8hWTL z&_nf%dJs9m@iMiti8uCgdj?Bfi}@zZ!3>k>r;9!06+eJ|Kz1D&af-udJS)Uq<}3}7 zbeWk>nn*wSBVYEgBpnD7m+?dQi==?fB7fo@b3@N2|E}yxOs(;J0$`RB+^0ANszV0& zq#}S7c6M_!SXPTlpRm$UT_1#+-)zwoIa-_M3e!m7Z-ME;$1~p0)tp3>nX-d=f{?Bj z7t&J0NzAD$<#%(%p`@L3YOjzo7e29~8|6W8mv5dF)PR=PDLIxnA49Jy{*Py;DY3KG zNx$OKtS?Wpo0GpqmBZPQl`foQ(sUOALTQ=YYruOkM}X< zg$$59_3)5f`Q+vJ(*|VRkr_+IgmQJ5<`!qC2twVm+%PX`O6vGZet?_fk}ST}@3NMc z>#Q8^2atPcbad=+4@k&NdF`9k(^WJ``l2rwo|-W9#cEvuCOc*+4OPUzI&P!U#tX_r1n5^vdJ$+0@+ps>(`}rh|wnUtCB@I>4qDM&GZY<+#hg< zGR>LdCan(+?d-k)VS79N?KRD5oGPtlw6B=}&xQEV?jq;QNu2iCx6OwX*+#r zyLDN-D#*t|Spd?UW9LP#mNvnJRuWfAX1ml#Jszn%BZ|!M6)1GrD26JL|JHey+LJFR zIqGD{@?K#zS3A@rUE$oTDeYy9p?Xy;*JQ@l2;IHl#C`RJQxh=Y=;lOf=MyT0y*0IZ zMUzILt5Jh;@dhu~!K}bG+wHQ7f^8aA2fC!k*|EKv`tZ zjp}vyAR28ew(-XO;Bp5XfQj2P)O($pU(X+-M{=p7R!y*wGT_L@+IOb$QJ zT6;E?h}-?(tR%gCK)+{b(u9hvfUs??A)XG*?a7#q6$b6RpNOa%L@WpJCq4!ZxaticuS%j$bsY8G0{MMCki4?8ItDELbIvjvWvqrom-kV9 z#9pd@p;S(&NoWCm=LKjP$=Zex2YHP9Kx0xo_?!q`iZdFdj27lj^(zAE6)knpmjCK?gQA=yHkvRo&%lH$;zA zTf#i%&t@t*rAnwudZx7U((hy=oL}(Pn(IMr)(wHxWe5tf1b=DeZWQh1L@q_xs(_`8 znT$)|^W+h)Q=ztPU4RIMK0J$iL!)G{Xeim?#Qnpe6Fc_uR#;C=TJzz8;T7F{PwMDJ6@Q3Kp?267p!;0h;N={PYZ}zw*aZ>Cp7P zbSNTM@Q$VnBbwfiVUb#V{GRUenOrsLq-exU{TknJL-up-;OVz@cNK3sZ*&??Z=e+l zCHVX|Kuz`@E?3(`M_;2bKvhTL~i|+7_JXHMt z&Zw3~6pfo`yE{%(LNexHy<9H>gNW}i$mVgy$eDcB9-=0}X4owWbu{6o@UcdipPi~( zm!o&LyM!fASYKY={t=NRpQt(Fe)!)*ZD13RgP!{za5ZRtBvN)$saJGRJK9Gvfhart zg(|g7G~PWQRDnW_UuYeR1g}j*0jo~>89WKBpg{0Dn;km&mk4>Y z3Z$cqlEw4h(yLt;7|V7>TsVH~QTHeq7Oj>NJKkF9w%2qP(TiVbG1IWm@G6I#H^5Ne zM0yFqIPIh#f1;8fLL-hQLC8XQ9bHJ!X^wKyZKEmWH=BnzR+NdNrO#c-Zbd4-UoKZ$tEoS2&cOQYK zZ>=Pi0(4rZg&wb-Bd_-s@ox2w_@A9-_449M`pDMIiKimQNow)Ju+FtgeKqCfrWoG1hRu_Di+0vBb*9z z6Of`Ghh(f~Bc!&C6rW`Jr#y1r<9;_Oe|bICJ&DidJU<^@ z8$AYH?$!^zI!$|x=#^nvRWm82kAD+w|BYjypsT&o+Nk&+994rBcC@F-ti1IdBRE5Z zslM$Uw@+I4`1Bv3HAG}};3YZnA3D}_nlC7x?YUzKaaxb^~aRUZsK@NEeIgrE4`pJy10Z*zUsf+32DgDrx z0aw|zXU47f6IHDv@{-8rGKRED?($RSKiO}OU4M?C^VF(QSMt4#nJGKcQ3X*=Epq#a zsd6N=3N>#m4{o)Y`~duqNdGacRO)B-O+ zbV)d$dU)29p@ze{uWCB zH=o!1(`(3y5hiZhSCS=uwx8J)u3h;&${6PeXHOeB-@W0L7aYdz@~5pY1%a1z#eO(q z>VjY0-eD2F|LjbOp%LSy^f2xVwH|KMyaOlQ*;Asr{%%ygwt7O&h8jb{igys7uWJ~+ z6AvSGS>!r_PmZbjI^YzdpZZl?VlUu_|4M#%l?{W-W7XZ4+Mwm{UPTq$}NU&3$vEnhfkG|2qKPbkLGEIil{Gu4MMvm zWl}W_oxF$Rc%~aPBcRwhwnbskM+WNG@kIe}DG zQ!;nI#%j9uuoW2G?%x1nVBeJG`vMi@>~T&+F9XX~zJa&c!~oFJL|gjU{6?kfAS|tS zkuv(<*Y)}jyePJO)b@uiWH>dR3M&q`O`k(;!AGq1PUBcK1*n}fj%r;t@3r^^#RYXD zeOra^buEsASe(X#5fFe*43Vu9I(}{>;%G_rAtzaNN$5#IzID=nHJf#3#=MsUCaFF_ZP5gF~JIYv&`)n>jbs zc2KDNmUaYvY%kuEX4EfXqEw(H$(S5JY<=erLMx==1=G#Ja;v?gC>Nu zCa+$~4BImYgOE94?*Czb@89GFC(ohZGdp1E^56{g-Y^Cg;Nb+F(L5)g4Eu}ecfAQ7 z^LAVRYz1i-Q7I3jQ^x8%yxXYq6;}TF*xu7@Pr$3`p_Ob$*Q#l$Z0(31J%dqgC$g{; zur`-$I4WBm9UXHz!XD>MglfVWKl?c3XhLSDd{Q%br(fist7LWK%#J2IsLkSo6lt1^ z;D${4I32skqI2$1`;_)Edkss{thfbKCnfb4>-hPs?jO(Jys0G7Y@sQOfJj?o3oEPx zwi>w=sJ<=*;uG{b_&8*13u=8Iwj4|?HH$o3`})wcp>+`)qBY_xqV}22(WP_J3Z;}d z>f%CGGKW}Q2YaLTQhw5`YI6p+ZWcG8aZ~M;O_n5XR3oXOkvX6{5jR;4vH5$duTO0{ z(1NB--`pbuwVr<|f^Akl{uf3LKK03WR#%oTBw@-GiCR=xN@6~{yP((L*o86gTjOx4 z`W$k7`m~u%A8F=(fRx}!;@ijuE9um=Vb>!A_Y53OYoK0!LeZ1xdMJK_=}7cSO^Ozw zranp|U9YX)5ffmZOF@ci_v7jU?-Ud^DG1Y?bjwHZx9+oU#15F`td?B+=?Nrb^BYV2 zA{B%f@`{-AF@qPT^Zlm045u)K#A9#^4#$^aXoSH{{VS%0E_T%YU$si=#5w_sDsCTL z8eT;($~R5LD5wkQ*yCBb;A(kW8U`D=bX-mzUh?0*v*gua+-{{oFI6xQ;00e#`wu`C zb+&B^=mwInoZ4&1h8QHN4EWx|M&d+P2~G4CNz%mayjs6g?q{Y28siPc)-jA${0A*U}tlEWZ`k!{M5TFV5zw_~95+CK)(^p{j}* z=}I`x#xcRZHZZ8M@~98jsE)xp=)AD80uam~={Xx{xujNdiite52$ z$tN>k9_kEgj3kKjdKAmLxi}_orCUTu6>YXZw&)~9k5H`OtH5n#{Ci%_bd13 zzJc6F8b0LQ%*}ac>fBv8#J-O`jR8uw<>*R&J(N?A-T{7HNK0CB+5IhNH=*3$zAU1k zv7rOm?05rgHK+%OaWo@PLc1y$2|3qf+F`+xO99f(y1$#*qS;q>@J{QHvc;7=LDCxtqQV{O6BTHoTd&>QAQqWAAv zyq-g7#?Jv$g=MC9?NdTP^CKhkmnp0jWANmrrm1(lBI@UmAM7jB&}cCHVZ!ZOs2{i7 z2DD)QVO(G=YmK?N@%9b zeiLjteWli}X*E}YUzpBz^7kEd)jA!28%ant_LZKqU|#9cj;lqSv!Y39t4GUGduQyr zbat+lAvA^`H-PAeK4VWm?9GE4A)0BDR!?sG~uQfXZ>a!A+sI;6LV zqi3yVjy(`WegR%b2m#_>nAg~H*}OK8UR0B9$oOO`pSE^=^c%o*waY1eMiyTrd*((h zx4XYms`41X`RT;G-A*y{V&Hm=SsJ+ZUFU!cozC~Q4T0>rFP^1*?m}n(DS8Bp*;)G- z^EbUnubODb!f9=GQvim8aG_uxjsyH&%amC^9*q>NJei2~o;~43cg~tqTK>H)7tC(H zqqZd{9VmZ71BaW|5!vQecB#(I7Ob`0%Cmxk*qZUeb|5_6h&ZVk7aGw_uP4!CEu4{I za0Fn=J7jn6`Ek~Sd37Zvcwrb=1!~nLp^f*XpUW29JVMi>i=@)luIyHLuPnnd0~Jqs z<9CgW&kM%y1i`h1i|<>~Eu6|6;d3XFnAhXT=d6n>?4e`ER=2Jl;(<_ZP1)NA(wLhL z{*XX@UY%L5L~F2F-96=q^wNQvC3bKw=Gm^Mrj*hOk%yzk7Gm+3;9amu-7iwY zQ`rr%cU-ic`_)%p!6YJbD>|i@m0kRFMuCA_meiu92 z8u1n3CaGN($CDjslcC|xN!<{IR03j))Px#ROvSg|4P~CkHcb5Bflp8QS>k&ANVDSuSUFI`f`Kvkv8&(E_YJXEYX9w&EJOY-?;cZq$qLlt{t zawQbhrtYIV3~|YINZ*&kA+C+!jHNLd8M1$EMNSIYRG2^VfI1sD(N%YBH5@XJm<>=6(a0DhZ2)GU6jAcHG6q0cY za<<@W=(2ueqwgTxlWq1MIxbaj86(_VJN9|m*lgr;LAxwZtIM(%i0}R|9|eD|O|Uf zpoUU~*PD=r6x&zHrF~g9?e3GJjdIaRgWT48Cb@?xa|x8wgtJ71UsAAp0-9w)B)*UL z9j13xSFt$B?hjLRF(b4B48-sdK~hqhn%nJ4G5bs12(BFupd_XvRMF}pQJe|>5T1QX zK7Y39o}J;%vQ}IHz?OHJ9L-4Kx;zB;c&o(tqtb;^XBSyZGwfI-Vtb(FE=7w~_vF|c zyJ??{#K5JTXN1iWQKO@#nED6f9yFPpSQ8=?dK0YwgHhnGYfCChE7$HQNv3cC*-V@z znf6*Udz?1-&5*|i_%SV_0a?*OHzloVwu*C@4Oo)ZtxJm;N4f2_)n8imO_0~nc-*}! zhr0UM`a*Y{&o5_q{Z)i%qNE)##r$3_`K!yDJ^pShs5er;wKAIMiiMTNR1q$vW z!^tpIozLv+sgW$OT0_j8Ms2?Ip`{t#Jd3a&SriwZo;UV$rnl>;2NX+Qh9b{P=xWpL z^)PDq&t7PD8=S?`D#G+m_DCnbRnxBo63rg73Qsl0`Bz}_;q6nY=_9HUcvG?D*uTg# zer{?cKm|iu5=ccQ_6`L*AV*v<5d`^oQW!CQ&q0ID-{k3oY|;duPEG$`V&TKl04T{! zizY+G9C^unJ>Fr;kAm4i_k}1=O1i4#J&qZ+mG!*A0H&}DdG>RfI(Tp}U8EeegU0G` zejVostB{2XZ((dH#5day;D~G@7CBT)LFVZtIR;|}QSkdY2)%n+C-&~7!hKI};VZ~CNwR%Igy?DRAfuQyMU1Qf42daRzhw!_~f9CeAu84z|-_3uu z%K&ShP3zPZ3Y13-Q6Uby@(Tagp6mZeFp#Nw&54owPxRqQ)npcP`*t35U2|iyO5DY+rp}6GHe`h+PH5{4 zA!(}*;ck4$3PTd_6nqGnlE3i4MFyf)ky;Y{iH@;5?`VurR}p_>_sc&Jo*dqI^R3Av zZ>3Yoo1U}V5M(0XaaoSR4@M*OblIS0OgiNZC3R+uUgq+2<877nXOodE>bzQfJ>d?5 zHL@DRrDS?AuJ&7fO=TY-lCSeU7re*Znb%cDTOVHswA_iXl| zI|~a6d$h?|rXtCGSuHL}f}%g{wO=*+xyNo!J)%15MvvRsny@f+&Jrkb!Uh4ge!OYk zA(PMl1EQPeR-w@y>ilvPNJGkia}gjlq!0<_C(qq`74$l_)Sf8SfNy8q2p@!qW^lcl z?3VLW@Zb2%*dmEh2Yg4MESwo{O?YQvG7W}YY{+raUb#quG$j`~yVa=Uegpjeq&!1` zu_ps4ECiK*qY!8`3~DfC0K$Z*+UKR!gMaSOtbUbCp?U928MZ|KT4X!cl~anCR8%yY z5VAHQ{0|=i%3M6Cx{b%irlfY#93wcu|JB!5hPAmh?FI|(PH>7OP>O4D2vRI~kqyC} z;uI(@!J)XjyS30#iUgP9R%mez6lkHP(DsDA_xqjqd_T^g{K<1Y>sd2v?zv}XtyvAc zYekC|Hgu)QaP1a~7KYwR;&s`f@kG9#N13s@Pq1>fat3x*Gf|5#Tt7PD(Ulp$vGtAP zH?Z@P^b4K0KTL$1T%^_PTW$+&lEp@eGw{S)$rY#sF**s^K0Dnd%c~eZSSL@0@|3GY zGczhE;mu-&#*8H@sx>lX5JcfiKqOPlf{ew}T=(j&2eTiK^7K7dPuv+(XZpgMHs`HF zI{sOTjE|ie{BbDD@Sq_tAXX;`ujj5x>HGeFaj5@yHI1IIxETcO{HVexryh_f62H&k zpYROP^iLOJ)Vw61g!0C~du)S`kPh}YvEHo9lWjskRz_8u4$SiSn!1#}CAY$jYY!Pq&_T?lg+`jJ|PwPLBpG?#f zjCDHbPGIGfwznmV(78THN@FuISKkGUTAMCRRKMI*vu|mq=iiAV_{LQ6ptU)h+&HF6 zF%xixT_WMJ#IiMh`ag&@R|bLbw>_4>K&bnc>0w0iuuO{gc!+d+!N@M-m8(Zk{A!;@ zo%@!JhwBkMtsncde^D&oJLir6>^{gF^Yk<4exBH9+nvf~oTkDrjNDu`8Fpah9;4K7 zqCg~r)%2`?6huo@uf#z=%)iZ$TfzRKruma_1cD~FxDlr0{ki2{l4()PPJicktG!}x<&PR& z7?NyYjkD#dsRbk$X%t1Jb@6L8j*TuROP!6%TI#VBHn28pL`}{q$YL_n^9Oh9kCEJjsG-4Hsf?uBlxw{XHY)0;@@KX6GuV5oBx*_LLk1D2=oc(sxDD zn>MhnUB728XTR_@e*tZ1j_R>d1Y}>`3go&rP-FkH(((rHmgm*Hubzm7+^+{FX(T;bRLU?V z1kN^HDZ?MYnH>6js45u%69W?yh=UKr!o$EupAW^r1YnU<2$E5*sSav zihAK`r9!&a-t7Yd!V&UHR*^6JXSe6{pM{m-r2prfD6t&+oanw@D_$2C4WmgeqEcQ6 z*&}yNePSh%b<3xD*L~xCiL7b12{+gP9j2zIgwQ<#C0H7}i ze^FOnE0zW;n0~AM1r2FO?*s`Er*lBPz;qFcU2I)bar=Z0(X)YYSg}Esq1+AM&4rFk zixCY8ei3Q-)i7yix+zJ|I-pH1D*E`k#DKZe@v0MHX5|w0utXpKcsxx06iE9hFp2^n zh3S&m_lcvd47cIq1o^1rWd>hg&`EBQcKCTg0%M2OL(MP7Sa6C$70XGuM9IE|@G5wHql`#5AxaY=Z;r@0BwOOpMT4Gw%CJHo>w}Y+S@R? zNyb~<^4^KcXD-+S#&*Oww2~=EVTHb8UMaTKT0o@;1j^a)*yhdOvo6QHoIUviu)Sh= ze-~s)R-yeQR!r!>KI=Dls+N&Ovl?^fSCnM$lFfw_ADQStB?sQXSRH|LYc$%c}I&WaRXz#=^ z^sQ1jZl5FYJCv8Ce-B`id`^}SA$w5Q+gniibdBdmHxCjD(wx!*lCfRpOw2QeE|*M zAXnjjey&N&kC=>t`e06ix`_oyC}rvLITI5}Y}oVInTqP59MsdCef6YHaMQ3S@|-e( zpW~||u-^;zRv}}h9=xtx)^$XAW^26lU6!B+*>61HV*piz`oWz(>)hErHsV+CP)~5j zO1YNt%sKLsb%WPxFMl|q3j=)8tk^T#;*ouDCR^z3mA8BF-5$7C&SXhGQ<7!v3II(8 z_Qq|~S_w5OtNa1*fqry=Ay>&W)e&(^`-3eCX~yh=7%scU?@Pz1Y9p;jeq(QFr}30| zc1Pa6sb=_PP5AE7`a9j_x*XBQSmM#nE=NOxy3PUsP#ype%=WjQbY0iZqiPEJg7HZI zO_6=WLtPrwkQKIzMVw>FOYJ^DSgK2!pZlRCp8wmd;^@(&*5DGt&<00YqdL?4j0aZk zWgyB=?4C#ud_cCi&k4|=O5R6I44#(;Hn8>h>!vmXLT&H$H2&3$ zx)j7TQ-~Pxu*VM%3Bd7R(;4OzB|$x*aobI&TC23_)II-;%eT7)Mdo453$k7wZ4Om( zX?0$&JdKrB8D7L%w8zGgjzeb_#XJ(Uo)qx!Mb&z06f?Iku#FcA!EpPu*%Dbc7A2Ng z8OBv}w%NhKyEp!b9kXtbU;S8W}9r!I*=+@xkezd z8^m~-i9|$=u;;y^P{(nwr!$N8$a3GfqA7^0>QBV9Cy_#;Vti8P(N%hnqrwGuSpLi0 z_Fz*_KJ~~Idt@;O7^6WU4R_6gVMlOoH~2I6lh-c}(vw{LQrdZ|Xunj{_!>&HXm@vO zd3oQqV|mR~q?Q^Sta1A~bZKSpat;r#d{}r}DcV+(IbBOixNm1n@UF^iz8c*CC{!F(v8TS%_kr6D#x2y{P_BZR{Jp{s0R9I6p$JVC-~`#$9l*4pekX-KwOxojKSx#qeR?W z(`O<}G#H1JBZ28mu2&MIzJaB|hB2%a(W8XJ9@?dr$~Xnk`owkn$=nh*jWkjcF@;`n4;l*7_-s66@?f+`*fT*`RR&Ent9HO zIkIE`f3r{7x`AX{jGTu_zi@w*zR4Yi01)pEU|5+%DfOA^DehrWG}RXcikje`F;cwD zf-qv5U(suM_Hy#3L_`8UtDD?SHY2Fk8`??&-IfT_%gybj%u{mJ=|8rr?3pGWbS7SA z5B(x+wI+6xy>#r4sD2^F#IV07I`-jgl6XR?J%T*rQJLF2%`erzh5gzIQ%X!?JIK7) z^)w9%_Vw9GZB4cR0FDJcqQkHg5*QDB9eiy*Qj+1I_=K`5o)2kKXP{k3S$>SZwKQG< zq$ae<5q_XwsiitWrNiCwyK3GjQ(TtiI%&5u z#kr+W@G&uI#@q$5#F|T;26J~1?dc0nNc_oK)x}YK*cJJ6y;|N45be?9$}{P>7Wquj zzVeeT7c7?qkHW>m#S~XfdD@niajJ^X><-Pp(#`3T4=O84sKcf-P{csZidq(O;Bg)3 zq-E-h+V6P~7r6r8BpNNpB0q9t>hxt_d}6W7*}AbqO9lEb96MWPB@%B7YViEviO)#& zlYDSFH^j0%jvozvJ(}C(u3xEtP>$s_dlCvO+nYw}@O;}1xnGG^G53^gGNcmF{t}vv;(ad?wFh4 zJfIFRGKcnYwTDZcZQ{z5) z!QapVl<_zgxwHwVy$FKpu?k~&sjqXtGaUO#i09mklz$D-dN$i6%*3xcS$eGh)B9(C zt|6B{(HZ?vMmX32JDE3)g`8dq4C>GOA2U%DF3{*BRr};0wZ6!_8I0}iXCuJiMjKR>MJg`0k+CvhRD zlGSz^dWCGsVs@JiXq1co3&*U(WxZ_0TB1$ zdOr49FtZGhMns}s9av0qfI4X>I-UtfWWwo87P!8gZ{cU6%!3lT9nvkr=-U{PRs9*~ z>=;K+pOm6H;H|6A>Qr3{_u?|O+nSAWerV?u0SmdZ5l+MF`){gZ8R`nlod*;+8g#g{ z3cs(G1p35%U(ydEPv-@PVKgfEy0>xd5rzdnfl32bsEEQc)kI)Z<0a^}8u!F-_!DIb zn9Zwu3*3z_?@BegKjTV0?-Ht8bXf6ykEa$t;gn)Eorg^~;O%$^71mNdUZy_Q)}#k8 zs4V^SF}oOR^{KF@AfxD8;%zcIp_K@`x@MkiqHc46jt8V|Sr=MQE@#7gpwA`P`X=W; z9DzF*J~+bp-(zwRy5zv|m$3Z+7#*zw8^>wy)?D&4jzn$_`_?-GKQB1xmDW`wUbL0t zl9cV4HR$+)TP`rb5n%o4zI~mi8FevM!X4J~56IIxmg$qTIV#DbPW)C{#v{%2JHIVf z$v*}OgaCP|;eBztQ*|=yOXznmbIZLYNafzwnJmtgS}#(>y6d3R-}V-iCnzxih?w1k znTrSL1m-70hduoa_pMJ&pIl>{aLWKpjI=eyr-tajHzGf@jw_^xZqkv3M95>yS>A=J z5I4D-q&>b(AaP4witwsyt`4=ZS9jmnn%Z3lhGfZyn`0{`k9?fm8Y%)`$(>4+^J0;9 z!uqgsBe~c|3ujA%E6umXR;Z(@pE5PvW*qV9d}XaUJmURZwf`<*%^7u@5Q_-5+bko^EBVWVNrK>WO^3sS*A{{54(z zcXz26!1YZjX;Ptu#NNI^c21mJoZ2c0jnC~Rj5}<)ajizTUwS^+ZufmAv(pIAKu-Zb zSZ&k19>1$T`oa{5oo*<%lC!X?>O}dKQ z4dDg@c!6&v?;~DDJx6EpGpTziYXn@FtOshFa$g>Ydr75`%N)B)iY9(9Tp%swGO);` zdWvo)AuiO#%+s(N=8;bcG2nasjHqVRn9zEZiEUy5x#A8TiYUv=3VK;SRqw!-hm@Q) ztd2%~UlXg%IHqyca*I-_X?S738G5sho8yHu@HLj5xPN9hdZ>4HDidn^2S8?@;16G; zF|evf_p=D?Tqn}XoQ?rRh)0%EwF{17bQF@5X2vKpl^kO39zF=0$V5_-n?FH5uXok( zrlmz-xJozQvH0Y_9(615)gsaOuJi}6wq~;W#6v2`0jCCk?t6bi2;(O#Pt?nuGlpN2rw(o)oYEwU|_3Z0hG*rJY`hoWG zNJ-)pGOc_ZUw2T{a1t@n@zQ!3YH!yWCbxE_lHF63I;*9{^*br@sI1kL_t(>3z<82{ z6vPXXnCkk!){2tDm4*F_=Y&Dtg`78d@s(!LEBG5G3#sW#&=kX;E&_V{pjOr(I{BTXUR7W!w8i6>OmLzEPl=OQnK%66)Q`$ij@wPt&<==KB`ce%Qp z=&N{Nk7~C}Oe&>w09gb}bkSi*eF8P2vkSe`Q+|9gl}tXmB|dMl&n!&Pk*Q-{0{gXr zvL7)lqQ=F-a&>dTi_r8ya-1fI-v! zs*@9x(TtREx)p|GGM0k-f1=X+0PqPAK*y>O9(I#PXD*bgfMO?gTH*OQeWH*H1#Y~7 zx}vCpCy$BNqa^wH_<}ILIxShfTVFoHFrC>oe}$@%uV1rg$tjeV&YY79 zs!RyBP@YHZ?3WC_%o@3%nOFK$b2TF~*dNI`20 z_OhxIl$Fa8Eg&=c8I^uKHG$9@c8x9ZS2R)PKx}OSeyaw4 z4Kmi1I$=La42*AJVnks?(0Ei_|3b5bAEsX?f9oP%7e|0F(_e~u*8KtbO4&G!RPL1e zdT6w+*jf-XCja2khu=4X%fuy(zwXf14Iv?58{eMwPeg16AO@y;PZE(ZJIm+Eib1@n zlFc%vlFZkVXKym)p28w{)~2Iwd+h?HgBGqemdidbW-qI-gCBh(WNDB!QN6K+U*KJd zYW;R7FN=7{Hu+1l6gv1$AKHC0{qin~{$&&~(;%j3KaHH@2ee{&qltD-0XH1w?di(d`ghv#{4hHm)Fu*^ILre6*)fGuQeca7>kpb#m7*5gq zYsG2OpG4IL(PxGA--@({(@-E|6@q~Lp3CA?ql&_PMeXERr9P2)%Q)$<69E>*m&FR) zJsG*NlX$|$deysjI?yzmPS7)yloUh2nqi5P&8L!7b*3OTahuM#6DeK+L+n3*httZA z^)E~$c|Hvbu1&hzHOGO4Npr|`s*;|8YNPz^Upw_I8{DR#BTDo471`p#ms^wiy0HfZ z+#N6N>;U}VG*N+*e=8l2m6`0Ut}>+T;vHmKyof16C{@J>Ko(@ZgePwD(x3OPL7DIGI23rugZ@A&YU${3Tc9vcjLHnOi6 zN2Cak9oI9T1aj&7`kv~0iZXmT8jP2wai8BWHXrY#|KI}p zz*9H%i}x^z;ov~($t5M`kC-G4ui4}GT6UaE2F%x+tzL!z3}P({bg>3Il6D-NcJ3i? zV_~g&mGf6N#{qiD8WBk=qf@NbMA=rb73`_?Um1UK>B*NjH(YXYo{mg&%yHe|$@?qX&Eba! zD?uwI9j`2_+Hv?DpuGiXQYWx1D)+0XpkB)&NMAO@ik2fJ$=fUW`1O{z(+$h_XeIqx z7a;jGg%TViS@}w#8~Vcbi}{n=Z>QyUS0FzjKHw>o_-yN zhIhECN~N0&&_3YYNEoGOi-SzXfS1EVoF-JXv4`t$&o>{Di;hE&9Sb^Iq=>VA#)EW* zPDP&-o*LZ##D4@nJ7<~t)$`sn%7-g#B}F(62rkxOFB~C3DKmBhVC&dLvk47~Kw;4pi6RLH;M03F2Qlbm{-I6y^&UIh)!JVf_ zTZPluQ~q!6#HBc^iChaUtwsjsZ5d&PeC6cc%jg)+CA}!#KajgQ79Ow0x{p_+UE=%3 z7Nm-f>OldTf)GZg!OoEMG1&5}1BX#%n2NOaQX$Fvu){;2$5>hrjIcXxI|#I#N9Yhpj5R4KMkjnR*809nho>t9MsIUZb> z)BS*oUojn`=kq?ml$YAd8(W!10j3U-Hi*+Mfz@N%ON|x&Z2)D_Q*uZW7w~)E%g06cIxjmbX9GqmALHw52RbU zH~*DT**430;D18@nPVrp=YB~j7 z&=@i);;RBUwTB=6FLgnpn{YAGk>W=52;8-`7EmL^dLn=rA&jP$LU@I<#EL(10Dd+F*5=m>D1q(4pizIhP)y zlhQu`C)gl`F_A_9K$qmw3Tn;=0Qr2p>9`gjNxbtJ-F0}YCGbmK_(eN+9JdVBe~&Tm z)mMWnsZrLYS2n!Hs~2dS*yriAL6IpcKO*c#ZAq?M5)R4aCc|8@!Ngr2(V6-y^G{EH#OHp}?E(jF)mTyj zNH&PvSNL>`I5p7_ERR}2yz;67)#%C(l-lW*Yy-?lB92!w%u$G~#who%CQmb?`^nC3 zoi2sD9Nxr}DRg<9aEHXZn@|G|$Op~vHmb@p;nHQ&K!Qyo?mF0rD=TQ{^__-tYNb4I zdySA6NMCExfUj5X$nbyXl=Pqqe=>?@ZZpP+7Mt!x48u?XP1YyRtSP<9T!diKIiXOT zLG6PYGkAiUep{H=gW(FN)iC;0A432QeFA9u5E1UN^02g>0E_#l1Jlz;NXpCaM$%cY z$a`_^kpm+he*lt->C(dl4Ox0>E4aCz1PpE4UPAjG|E$~Heqcsan~b7+DNyUN4QtlR z;Y^-MRMzMM=xJ|;A`&ABL8NMH{wc*eI2kBhN}R$SUfpWl@KpYio4uA8Zz~-<6t#iG zgY*`RU!`twD=UQgI+F}evSdSv<@K*9v@XyEvOj=-NbKWJFYjWDBbo(eJdR#pNZEzb z7jj>xY}-_Bv-DkA*1WloXukfm4q)&ZXBPZbz8F$=lL>j**r4EfA=FflTh$mW(R{6b z`v-u|4c-HI;FC)R5K7;oX(Ph3W+?8)6rER?zED|9N5Sv2T7jSE-KdV=SXvfQdiW7c zse^$_!kM}s#YlwxS`%0*s~KDkQVP+RIrs@Nw^nK;d?uFs1$)J7{f6@2FH0hlA#mwj zL%%v@ZvI^GFaLP{g<>0GJpReyK0LV|@9+@JP=oI?J0=6it7*0gW4WE{{yJFDLVR$nGV_fcD2$N9cdTkc=reJQ)40X z{L}Rim`w1b<o5OUh_6i3(JVAw7PR)m z0bO^TLst}=NaK8iK`hgA{!rfR!7Ssr2MY4_H7S4%5MGSon~G5!WBKt)0ksJlF!=?hIb2z{{5QF z0VY#)K06oc7|)mRMRHVCT8EW`FdptHH^QqzE3*2m^=#bDmqWm&u`oblMY$?`-P13l zPZUBPWXX!XtxHa~3aMI@v49$y%~!0p2kk`NF9-H4dr1JNkgxj4g$Q)bOBC-~Sr@a0 z20$!Ze?@ywVJMe>PZ440>x(hUlcf@C*rrxo0QIfL1Cf{SIajo2x|T#?+T(bWl)Q!- zRt4eSP8)&-m_eLe>@P3w92)xE8sS0Tp*KHjQF0uVyU=Z^>hv0(5wYs*Ii5D~rvo;o zrb(NGDuE{gJB~6DZ`y*yavBu$TO!fM_bNHv7-{7-{yr`Y`xo2yRo%Os$j7;m_8Qjx z-7vfz#=UOn9+RTP8_ceiWU;P{h4v;QA}V|6Jb5H9Q9s2yfDR1)N`KIoKtUphKlv!5 z7D7W0!cPijN0-CFoTtcLT;%YmJFHvTtwv+po>4yRMXaXyk8w9^l7R{h8Y2HUjCnrJ z1s(juHT$EjD&uTyDRxN8u>w}^b!j6*xH>4@t>P}KZi~v(UMzorb&$#;gP#_wJ-9+4 z@Y8s+CFuO`#lhG(QFqGJ{?#bN`NNb26PkfK)O{ici}4YvkDUQWn7= z9O0j_S%~_LbBbPn`>3j3zb$y|oN;alD5NHM!k&mcYMl<-?MyIW&@mDL*Z^-=thX&J{)B_(-N#kc#m@$gkD9+xi+iQ z>-fOg4FLww)nG|72Yb;QgqoX~!(o18bO##Z7|7oe>mvvKID)pWl~phd2m!dc|3L>d z=S?!8!40rfOCsLAlC?5-T!@>e?3=IBy+|U8$oT^hh?WAg8s17*e;bEqCVX_1w1e1G zeWp%agfK4xNIdH-V#&fToFn=-$P*&a$p@DqWa)GlIt zj6w*RYM7m5*zpD?(jE#rqW50N7#}~%{YQC_MF+XPlv|BVdH2ILD;j_;Fn)COS8ZCR zr=I&TWx2s#=^>}liE||RjD_X)G2Pc_$6~6|rxtH3sD<+DYfto_zpEu1+?_eAAqXfJ z43!7qyH)(!_?PazL#M)m^V8|T**M{>D|_f&0-9&JhPp(o3Gf`qSIVW|`Hn}T!BA32 zh|-hi7S_^)Il=WW5xp>7=9%F~nWDcTZvqW1%eB*Qm1VWY5~(I41~ym1oi>EuIQWdO zy@g|&;h?uR*f)p?6+s-{>QXt1mry?zj}O9QhgetY?#oYU-diM0FKE}k9*MLX9~4lw zgtnb~{E&61`=xSU%fbG)m@`JG&*or`_#XI79~I7nW!XzL zc@3XK`HN}wfEg8Ms`{z8;Qt6*%H{{HVglrL(u?AEJTc;31Y=Qc|F>xzZeo##S7P_s z_Q2)^OJRl|%2zDCrY+}_+V5!;Me;I1yVyqrAy}@5i;|A_T>D>kFBp!^R1!(Dp`TFQ zH!YGQKC|+{ccJ#@u!}*3Ul?ge4-xy$+4?`JzbQ1O*5R^A=(@}|PlouBXhU$UmfqUxL`AU(n5wj0GS9O6O z)-wBuUJTQVzl#=Jr^z#hL5Ekaa_~0CWJ&M8~z4vDO%b(%27Zh?{h*XY}ve6vSu%<#o({x|LTfUeKfC>Nirl@7*bJGxQ$?w?y`m zKU4N$&y+zSy=JKUPf70A%S<2HxJUUvYJ4v35@gJkv0!ZrC-qs`$y{aGyoLceWq%u0i z`frs&^%_W&>D0w6nssWDwuz55a)gG5x<5F#laf+`8#6+?PlW%=;UOCqHmEGJ31Wtp z#0GPSRK9_UGV;ZxV02f^0|z41GH3W{^J{>#^mCG~JaD{mYZ5O{$v_7d`1 zSd;Ge^yqv6??QZ&yJDSNSR-ZTVTgQ`*rTV?l}{Ktaie^Ri8~LO!q7&&_fpoHvhx_M z!a|ctzE*E54#0+?YrWq%?h#$MN-o?-00*=IPs>kf8;s8pGN>Dul^>Q(r%+>Z9S))f zd^7h|?8JXk@E}^KQ3zvmHIix<`oacrUu3QlJ&!w<3yE1<$ac|3ftJu>P6H~ezm^Qu zXeZ=BZCl%GyK37`C+TPXoe2Clj-z&a4GD7>z95cm*>!l6`BZggunjeogJi3n^XQlq z;uFXC9{LR3Y>wIC{+B`9RoD7vniGqqDJ_c=NQ96lxp%@2nxUA3`BWGwPGq+Iz(>V7 z-p0CvyAFOtnN&PCcv@pT_*|4;R2UWeL%>G`^b}!fU%$#R01u?wH&h5@l~jSWlwBfg z%XZj(?s)QebKcnI+M8h}7^!4-aS9#uWMND){Xls_^wl>t?%s&9R6>9l0GQN2BQCj= zO=eqDsygcEz;o7Dz{uV@qJ6E`*s=;8h3<3)BG#>@3RaY1+L_V@`UTms{(ZgPvkgR#|%vHyg4xh4x;6Ii=U^`+)vm$ zQh|o`6zGqQ*+&#M^Y6bi-bS>PwV0PhmnFCdzX3<0{wx90Ilpmk2g Date: Mon, 5 Oct 2026 16:45:14 -0700 Subject: [PATCH 18/68] docs(library): update langgraph-alternatives (#8640) Co-authored-by: Sim Pi Agent --- .../library/langgraph-alternatives/index.mdx | 240 +++++++++++++++++- 1 file changed, 237 insertions(+), 3 deletions(-) diff --git a/apps/sim/content/library/langgraph-alternatives/index.mdx b/apps/sim/content/library/langgraph-alternatives/index.mdx index 0ca065ec90a..fc8e34b1dfa 100644 --- a/apps/sim/content/library/langgraph-alternatives/index.mdx +++ b/apps/sim/content/library/langgraph-alternatives/index.mdx @@ -1,12 +1,12 @@ --- 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. +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-23 +updated: 2026-10-05 authors: - emir -readingTime: 11 +readingTime: 23 tags: [LangGraph Alternatives, AI Agents, Agent Frameworks, Sim] ogImage: /library/langgraph-alternatives/cover.jpg draft: false @@ -21,14 +21,74 @@ faq: 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 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." + - q: "What is the best LangGraph alternative?" + a: "Sim is the best LangGraph alternative for teams that want visual orchestration, self-hosting, deployable agents, integrations, and human approval without building the entire control plane as Python graph code. PydanticAI is the stronger choice when typed Python application development is the main requirement." + - q: "Is Sim a LangGraph alternative?" + a: "Sim is a LangGraph alternative for teams that prefer a visual workspace over a low-level Python graph framework. Sim is not a drop-in library replacement, so migration requires translating nodes, branches, state, persistence, and interrupts into workflow and application components." + - q: "Is LangGraph still maintained?" + a: "LangGraph is actively maintained as of October 2026 according to its official repository and release history. Teams should still evaluate whether its graph-centric programming model matches their operational and collaboration needs." + - q: "Is LangGraph open source?" + a: "LangGraph is distributed under the MIT License as of October 2026. Optional hosted LangChain services have separate commercial terms from the LangGraph library." + - q: "What is the best open-source LangGraph alternative?" + a: "Sim is the best open-source LangGraph alternative for teams that want a visual workspace because Sim's core is Apache 2.0, while its enterprise directory has a separate production-use license. Haystack is a strong Apache 2.0 code-first choice for retrieval pipelines, and several other frameworks use permissive licenses published in their repositories." + - q: "What is the best no-code LangGraph alternative?" + a: "Sim is the best no-code or low-code LangGraph alternative for teams that want to assemble agent workflows visually while retaining extensibility and self-hosting. Complex production systems may still require code for custom logic, secure services, data persistence, and integrations." + - q: "What is the best Python alternative to LangGraph?" + a: "PydanticAI is the best Python alternative to LangGraph when typed dependencies, validated outputs, and conventional application code matter more than graph primitives. CrewAI is better for role-based agent teams, while Haystack and LlamaIndex are better for retrieval-centered applications." + - q: "Is CrewAI better than LangGraph?" + a: "CrewAI is better than LangGraph for role-based multi-agent tasks that map naturally to agents, crews, and delegated work. LangGraph is better when developers need explicit state transitions, checkpointing, interrupts, and detailed graph control." + - q: "Is AutoGen better than LangGraph?" + a: "AutoGen is better than LangGraph for experimenting with conversational teams of agents. LangGraph is better for applications that require explicit state graphs, durable execution patterns, and controlled transitions." + - q: "Is PydanticAI better than LangGraph?" + a: "PydanticAI is better than LangGraph for typed Python applications that want validated inputs and outputs without adopting a graph-oriented architecture. LangGraph is better when graph state, cycles, reducers, checkpoints, and interrupts are central requirements." + - q: "Is Semantic Kernel better than LangGraph?" + a: "Semantic Kernel is better than LangGraph for Microsoft-oriented teams that want plugin-based agents integrated with established application services. LangGraph is better for Python teams that specifically want low-level state-graph orchestration." + - q: "Is OpenAI Agents SDK better than LangGraph?" + a: "OpenAI Agents SDK is better than LangGraph for compact OpenAI-centered applications built around tools, handoffs, sessions, guardrails, and tracing. LangGraph is better when provider flexibility and explicit graph-state control outweigh simplicity." + - q: "Is LlamaIndex better than LangGraph?" + a: "LlamaIndex is better than LangGraph when retrieval, indexing, document ingestion, and private-data grounding dominate the application. LangGraph is better when orchestration state and complex transitions are the primary problem." + - q: "Is Haystack better than LangGraph?" + a: "Haystack is better than LangGraph for component-based retrieval and document-processing pipelines under an Apache 2.0 license. LangGraph is better for stateful agent orchestration with graph transitions, checkpoints, and interrupts." + - q: "Is n8n a LangGraph alternative?" + a: "n8n is a LangGraph alternative for teams whose real requirement is visual business automation with AI steps rather than a Python agent framework. n8n is source-available under the Sustainable Use License and is not OSI-approved open source." + - q: "Can LangGraph be replaced with a visual builder?" + a: "Sim can replace LangGraph with a visual builder when the graph primarily coordinates model calls, tools, integrations, conditions, and approval steps. LangGraph may remain the better option when custom reducers, cyclic state behavior, or checkpoint internals are indispensable." + - q: "Can Sim self-host local models?" + a: "Sim supports Ollama, vLLM, LM Studio, and LiteLLM on self-hosted deployments. Local-model support is a self-hosting capability and does not require Sim Enterprise." + - q: "Does Sim support human approval workflows?" + a: "Sim supports human approval workflows through the Human in the Loop block, which pauses a run and resumes it with submitted form fields. A downstream Condition must inspect the approval or rejection value and route the workflow accordingly." + - q: "Is Sim free to self-host?" + a: "Sim's core is Apache 2.0 and can be self-hosted, but code in the apps/sim/ee directory uses the separate Sim Enterprise License. Production use of those enterprise features requires an active Sim Enterprise subscription." + - q: "What is the easiest LangGraph alternative to deploy?" + a: "Sim is the easiest LangGraph alternative to deploy for teams that want the builder, runtime, integrations, and run interface in one workspace. Code-first frameworks can also be deployed reliably, but the team must package and operate the surrounding application." + - q: "Which LangGraph alternative has the best human-in-the-loop support?" + a: "Sim is the strongest LangGraph alternative for visually configured human-in-the-loop workflows because the pause, submitted fields, and downstream routing are visible in the workflow. LangGraph remains strong for developers who want to implement human interrupts directly in code." + - q: "Which LangGraph alternative is best for RAG?" + a: "LlamaIndex is the best LangGraph alternative for data-centric RAG applications, while Haystack is especially strong for explicit retrieval and document-processing pipelines. Sim is preferable when retrieval is one stage inside a broader operational agent workflow." + - q: "Which LangGraph alternative is best for multi-agent systems?" + a: "CrewAI is the best LangGraph alternative for role-based multi-agent systems, while AutoGen is strong for conversational agent teams. Teams comparing the broader category should consult Best Multi-Agent Frameworks for Production in 2026." + - q: "Which LangGraph alternative is best for business automation?" + a: "Sim is the best LangGraph alternative for AI-first business workflows that need visual orchestration and deployable agents. n8n is a strong choice when deterministic SaaS integration and conventional automation dominate the workload." + - q: "How hard is it to migrate away from LangGraph?" + a: "LangGraph migration is straightforward for simple directed workflows and substantially harder for systems that depend on reducers, cycles, checkpoints, interrupts, or long-running state. The safest approach is to preserve representative traces, migrate incrementally, and test restart and approval behavior explicitly." + - q: "Do LangGraph alternatives include observability?" + a: "Sim, OpenAI Agents SDK, LangGraph, and several other alternatives provide observability or tracing capabilities, but their scope differs. Buyers should test whether each option captures model calls, tool inputs, state transitions, errors, retries, approvals, latency, and cost at the level their production system requires." --- 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. This guide suggests two ways forward. The first is code-first frameworks that replace LangGraph's graph paradigm with different architectural models while keeping you in full control. The second is workspace platforms that handle deployment, observability, and integrations as built-in features so your team can focus on agent logic rather than infrastructure plumbing. +Sim is the best overall LangGraph alternative for teams that want to build, deploy, and manage AI agents in a visual workspace without making a Python graph framework the center of their application architecture. + +> **Direct recommendation:** Choose **Sim** when a visual workflow builder, self-hosting, human approval, and integrations matter most; choose **PydanticAI** for typed Python agents; choose **CrewAI** for role-based multi-agent systems; choose **Semantic Kernel** for Microsoft-oriented development; and stay with **LangGraph** when low-level graph state and checkpoint control are core requirements. + +All maintenance, licensing, deployment, and commercial-service statements added in this update use an **As of October 2026** snapshot and link to primary sources. + ## Key Takeaways +LangGraph alternatives divide into code-first frameworks and visual workspaces, with Sim the best overall choice for teams that prioritize deployment and collaboration. + - **LangGraph's graph model trades production readiness for control:** In-depth state management features are powerful, but teams must build deployment, collaboration, and integration infrastructure themselves. - **Code-first alternatives shift the paradigm, not the burden:** CrewAI, AutoGen/AG2, Google ADK, OpenAI Agents SDK, and Mastra each offer different architectural models, but all still require you to own infrastructure. - **Workspace platforms close the production gap:** Tools like Sim and Dify ship with deployment, observability, integrations, and collaboration out of the box, reducing the engineering effort needed to get agents into real workflows. @@ -36,6 +96,57 @@ This guide suggests two ways forward. The first is code-first frameworks that re - **Google ADK is the strongest option for Google Cloud teams:** Released at Cloud NEXT 2025 with native Vertex AI integration and Agent-to-Agent protocol support, it's the top pick for Gemini-first builders. - **Your team size and integration count are the real decision signals:** Solo developers benefit from framework control; teams of three or more shipping business workflows benefit from workspace collaboration and pre-built connectors. + +## What are the best LangGraph alternatives? + +Sim is the best overall LangGraph alternative for teams that want visual agent workflows, while PydanticAI, CrewAI, Semantic Kernel, AutoGen, OpenAI Agents SDK, LlamaIndex, Haystack, and n8n are stronger for specific technical or automation requirements. + +1. **Sim — best overall for visual, deployable AI agent workflows** +2. **PydanticAI — best for typed Python agent applications** +3. **CrewAI — best for role-based multi-agent collaboration** +4. **Semantic Kernel — best for Microsoft-oriented development teams** +5. **AutoGen — best for experimental conversational multi-agent systems** +6. **OpenAI Agents SDK — best for compact OpenAI-centered agent applications** +7. **LlamaIndex — best for agents grounded in private data and retrieval** +8. **Haystack — best for component-based retrieval and document pipelines** +9. **n8n — best for combining AI steps with broad business automation** + +For a broader market comparison, see [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). For frameworks focused on cooperating agents, see [Best Multi-Agent Frameworks for Production in 2026](https://www.sim.ai/library/best-multi-agent-frameworks-2026). + +## How do LangGraph alternatives compare on state, memory, deployment, observability, human approval, and coding effort? + +Sim offers the most accessible balance of deployment, visual orchestration, execution inspection, and human approval, while code-first frameworks provide finer control at the cost of more application engineering. + +| Alternative | State handling | Memory | Deployment | Observability | Human approval | Coding effort | +|---|---|---|---|---|---|---| +| **[Sim](https://github.com/simstudioai/sim)** | Explicit data flow between workflow blocks | Model context and application-managed storage | Sim Cloud or self-hosted | Workflow run inspection | Human in the Loop plus a downstream Condition | Low to medium | +| **[PydanticAI](https://ai.pydantic.dev/)** | Typed Python dependencies and application state | Application-defined history and storage | Python application | Application instrumentation | Application-defined | Medium | +| **[CrewAI](https://docs.crewai.com/)** | Crew, task, and flow state | Framework and application storage | Python application or commercial services | Framework and commercial options | Human-input task patterns | Medium | +| **[Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/overview/)** | Application, process, and plugin state | Connectors and application-managed memory | Self-hosted application or Microsoft infrastructure | Application telemetry | Process or application logic | Medium to high | +| **[AutoGen](https://microsoft.github.io/autogen/stable/)** | Message-driven agent and team state | Conversation history and application storage | Self-hosted application | Application instrumentation | User-participation patterns | Medium to high | +| **[OpenAI Agents SDK](https://openai.github.io/openai-agents-python/)** | Run context, sessions, and handoffs | Sessions and application storage | Application code | Built-in tracing | Application-defined gates | Medium | +| **[LlamaIndex](https://developers.llamaindex.ai/python/framework/)** | Workflow events and application state | Indexes, chat history, and storage integrations | Application code or managed services | Application and managed tooling | Application-defined events | Medium to high | +| **[Haystack](https://github.com/deepset-ai/haystack/releases)** | Pipeline inputs, outputs, and application state | Document stores and application state | Python application | Pipeline and application telemetry | Application-defined components | Medium to high | +| **[n8n](https://github.com/n8n-io/n8n/releases)** | Workflow execution data and expressions | Workflow-managed or external storage | n8n Cloud or self-hosted | Execution history and logs | Workflow forms and approval patterns | Low to medium | +| **[LangGraph](https://github.com/langchain-ai/langgraph/releases)** | Graph state, reducers, checkpoints, and interrupts | Checkpointed threads and storage integrations | Application code or LangGraph Platform | LangSmith and application telemetry | Interrupt-based patterns | High | + +The matrix compares each product's primary development model rather than claiming that any capability is exclusive or automatic. Code-first frameworks can implement approval, memory, and observability patterns, but engineering teams must design and operate the supporting application logic. + +## What are the key facts about each LangGraph alternative? + +Sim and the other alternatives differ most clearly in license, self-hosting model, and whether the software itself has a billing unit. + +- **Sim:** [Sim's core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and can be self-hosted without a software usage meter; code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), and production use of those enterprise features requires an active subscription. +- **LangGraph:** [LangGraph uses the MIT License](https://github.com/langchain-ai/langgraph/blob/main/LICENSE), can run inside a self-hosted application, and has no library billing unit; optional hosted services have separate terms. +- **PydanticAI:** [PydanticAI uses the MIT License](https://github.com/pydantic/pydantic-ai/blob/main/LICENSE) and runs as Python application code without a framework billing unit. +- **CrewAI:** [CrewAI uses the MIT License](https://github.com/crewAIInc/crewAI/blob/main/LICENSE) and runs as a self-hosted Python application without a framework billing unit. +- **Semantic Kernel:** [Semantic Kernel uses the MIT License](https://github.com/microsoft/semantic-kernel/blob/main/LICENSE) and runs in self-hosted applications without a framework billing unit. +- **AutoGen:** [AutoGen publishes its code license](https://github.com/microsoft/autogen/blob/main/LICENSE-CODE) and runs as application code without a framework billing unit. +- **OpenAI Agents SDK:** [OpenAI Agents SDK for Python uses the MIT License](https://github.com/openai/openai-agents-python/blob/main/LICENSE) and runs in the developer's application environment; model API usage is separate. +- **LlamaIndex:** [LlamaIndex publishes its license terms](https://github.com/run-llama/llama_index/blob/main/LICENSE) and can be self-hosted as application code; managed services are separate. +- **Haystack:** [Haystack uses Apache 2.0](https://github.com/deepset-ai/haystack/blob/main/LICENSE) and runs in self-hosted Python applications without a framework billing unit. +- **n8n:** [n8n uses the Sustainable Use License and Enterprise License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), can be self-hosted subject to those terms, and offers commercial cloud plans. Its Sustainable Use License is source-available, not OSI-approved open source. + ## Why Teams Hit the Wall With LangGraph LangGraph's graph-based model gives developers something rare for an agent framework: in-depth control over every state transition and conditional edge. You define nodes, edges, and reducers explicitly. You can inspect exactly how data flows between steps, which is vital for complex, auditable workflows. For regulated industries that need deterministic control flow with human-led approval processes, this model is hard to beat. @@ -46,6 +157,8 @@ LangGraph sits on top of LangChain core and LCEL (LangChain Expression Language) ### Three production gaps LangGraph doesn't fill +LangGraph leaves collaboration, deployment infrastructure, and integration wiring to the application team. + The framework excels at defining agent logic, but teams get stuck at the projection layer. - **Team collaboration:** There's no shared workspace or built-in version control for agents. If three people work on the same agent system, coordinating changes is a custom engineering problem. @@ -60,10 +173,24 @@ LangGraph's concurrency model lets nodes execute in parallel, which is great for None of this makes LangGraph a bad tool. For teams in financial services, healthcare, or legal tech that need an auditable, deterministic control flow where every state transition is explicitly defined, and human approvals are needed for critical decisions, LangGraph's graph model is the right architecture. The limitations become apparent when you want to automate business workflows at team scale, where shipping speed, integration breadth, and collaborative building matter more than graph-level control. +**Maintenance status as of October 2026:** LangGraph is actively maintained through its [official releases](https://github.com/langchain-ai/langgraph/releases). + ## Code-First Framework Alternatives +CrewAI, PydanticAI, Semantic Kernel, AutoGen, Google ADK, OpenAI Agents SDK, LlamaIndex, Haystack, and Mastra offer code-first alternatives to LangGraph. + If you're comfortable in Python (or TypeScript), want in-depth control over agent behavior, and are prepared to own the deployment layer yourself, these frameworks swap LangGraph's graph paradigm for different architectural approaches. Each makes a distinct trade-off worth understanding before you commit. +### PydanticAI + +[PydanticAI](https://ai.pydantic.dev/) is the best LangGraph alternative for Python teams that prioritize type safety, structured outputs, dependency injection, and ordinary application code over explicit graph construction. Its typed application model is a closer fit than a visual workspace when agents must remain embedded in an existing Python service. + +**Best for:** Python applications where typed contracts, validation, testability, and structured model output are primary requirements. + +**Weakest when:** Teams need a low-code visual builder that operations or business users can edit directly. + +**Maintenance status as of October 2026:** PydanticAI is actively maintained through its [official releases](https://github.com/pydantic/pydantic-ai/releases). + ### CrewAI [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. @@ -76,6 +203,18 @@ The trade-off is that CrewAI doesn't include built-in checkpointing for long-run **Weakest when:** Workflow branching logic becomes complex, or you need durable state across long-running tasks. +**Maintenance status as of October 2026:** CrewAI is actively maintained through its [official releases](https://github.com/crewAIInc/crewAI/releases). + +### Semantic Kernel + +[Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/overview/) is the best LangGraph alternative for teams building on Microsoft technologies that want agents to use defined plugins and established application services. Its .NET, Python, and Java support fits organizations that need language options beyond Python alone. + +**Best for:** Microsoft-oriented organizations and plugin-based application architectures. + +**Weakest when:** Buyers primarily need a low-code builder or the smallest possible Python agent library. + +**Maintenance status as of October 2026:** Semantic Kernel is maintained through Microsoft's [official releases](https://github.com/microsoft/semantic-kernel/releases). + ### AutoGen / AG2 [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. @@ -90,6 +229,8 @@ One cost consideration that often gets overlooked: each agent turn in a conversa **Weakest when:** You need real-time, high-volume processing on a tight token budget. +**Maintenance status as of October 2026:** AutoGen's release history is available in Microsoft's [official repository](https://github.com/microsoft/autogen/releases). + ### Google ADK 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. @@ -102,6 +243,8 @@ The standout capability for teams running multi-framework environments: ADK supp **Weakest when:** Your infrastructure has no Google Cloud footprint, and you don't need multi-framework agent communication. +**Maintenance status as of October 2026:** Google ADK is actively maintained through its [official releases](https://github.com/google/adk-python/releases). + ### OpenAI Agents SDK 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. @@ -114,6 +257,28 @@ The handoff model deserves specific mention: when one agent completes its portio **Weakest when:** You need complex state machines or durable checkpointing for long-running workflows. +**Maintenance status as of October 2026:** OpenAI Agents SDK is actively maintained through its [official releases](https://github.com/openai/openai-agents-python/releases). + +### LlamaIndex + +[LlamaIndex](https://developers.llamaindex.ai/python/framework/) is the best LangGraph alternative when retrieval, indexing, and grounding agents in private data matter more than low-level graph orchestration. Its connectors, indexes, retrieval components, query engines, and workflow abstractions fit knowledge-intensive applications. + +**Best for:** Retrieval-augmented generation, document agents, knowledge assistants, and private-data applications. + +**Weakest when:** General business automation does not depend heavily on retrieval or indexing. + +**Maintenance status as of October 2026:** LlamaIndex is actively maintained through its [official releases](https://github.com/run-llama/llama_index/releases). + +### Haystack + +[Haystack](https://github.com/deepset-ai/haystack/releases) is the best LangGraph alternative for engineering teams that want component-based retrieval and document-processing pipelines under Apache 2.0. It organizes applications from retrievers, generators, document stores, routers, and custom components. + +**Best for:** Production retrieval pipelines, document processing, and search. + +**Weakest when:** Business users need to assemble and operate agents through a low-code visual interface. + +**Maintenance status as of October 2026:** Haystack is actively maintained through its [official releases](https://github.com/deepset-ai/haystack/releases). + ### Mastra [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. @@ -126,8 +291,12 @@ However, Mastra is TypeScript-only. If your ML team lives in Python (and most do **Weakest when:** Your team works primarily in Python or needs deep ML library integration. +**Maintenance status as of October 2026:** Mastra is actively maintained through its [official releases](https://github.com/mastra-ai/mastra/releases). + ## Workspace Platform Alternatives +Sim and Dify offer workspace alternatives when production operations matter more than adopting another code-first framework. + If your team's bottleneck is the gap between building agent logic and getting them into production rather than the sophistication of the framework you're using, this section is for you. The key difference from frameworks is structural. Workspace platforms ship with deployment infrastructure, observability, integrations, and collaboration tooling as integral product features, rather than third-party add-ons or custom builds. @@ -149,6 +318,8 @@ Chat's build mode is where the workflow differs most from code-first approaches. **Best for:** Technical teams and cross-functional builders who need agents running in production, connected to their existing tool stack, without owning the infrastructure layer. +**Maintenance status as of October 2026:** Sim is actively maintained in the [official Sim repository](https://github.com/simstudioai/sim/releases). Sim's Human in the Loop block pauses a run and resumes it with submitted form fields; a downstream Condition must route on the submitted approval or rejection value. Self-hosted deployments can use Ollama, vLLM, LM Studio, or LiteLLM as described in the [self-hosting documentation](https://docs.sim.ai/platform/self-hosting), and local-model support does not require Enterprise. + ### Dify [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. @@ -157,8 +328,24 @@ Relative to LangGraph, Dify offers a significantly lower engineering floor to ge **Best for:** Teams building RAG-heavy applications and LLM-powered apps without deep graph expertise. +**Maintenance status as of October 2026:** Dify is actively maintained through its [official releases](https://github.com/langgenius/dify/releases). + +### n8n + +[n8n](https://github.com/n8n-io/n8n/releases) is the best LangGraph alternative when broad SaaS automation and deterministic workflow integration matter more than Python-native agent state graphs. It connects application triggers, API calls, transformations, and AI steps in a visual workflow environment. + +n8n is source-available under its Sustainable Use License, not OSI-approved open source. Teams evaluating self-hosting should review the [official license terms](https://github.com/n8n-io/n8n/blob/master/LICENSE.md). + +**Best for:** Integration-heavy business workflows that combine deterministic automation with selected AI steps. + +**Weakest when:** Developers need LangGraph's low-level state semantics or an OSI-approved license for the core automation engine. + +**Maintenance status as of October 2026:** n8n is actively maintained through its [official releases](https://github.com/n8n-io/n8n/releases). + ## How to Choose: A Decision Framework +Sim is the default recommendation for visual orchestration, while LangGraph remains the specialist choice for explicit graph control. + There's no single right answer here. Different teams have different needs, and the "best" tool depends on where you are today and what's holding you back. Use this table to find the right tool for your needs. | If your situation is... | Choose this | Why it fits | @@ -174,14 +361,61 @@ There's no single right answer here. Different teams have different needs, and t ### Two signs that an AI workspace is the better path +Sim is the better path when collaboration and integration demand are growing beyond the engineering team's capacity. + **More than one person needs to build or maintain the agents.** The moment agent development becomes a team activity, you need version coordination, permission controls, and shared visibility into what's running. Code-first frameworks push all of that onto your engineering team as custom work. **Integration count is growing faster than engineering capacity to write custom connectors.** If you're connecting five tools today and know you'll need 20 by next quarter, building and maintaining custom connectors for each one is a losing race. Workspace platforms with pre-built connector libraries turn that from an engineering project into a configuration step. ### When a code-first framework is still the right call +LangGraph remains the right call when deterministic, auditable state transitions are a hard requirement. + If deterministic, auditable control over every state transition is a hard compliance requirement, and your team has the engineering capacity to own deployment, monitoring, and integration wiring, a framework gives you the control you need. The key qualifier is the second condition: having the engineering capacity. The framework itself is free, but the infrastructure around it is a full-time job. + +## Should You Replace LangGraph or Keep It? + +LangGraph should remain the choice when explicit graph state, checkpointing, interrupts, and fine-grained Python control are essential rather than incidental. + +Keep LangGraph when the team needs reducers and graph transitions as first-class concepts, durable checkpoints at graph boundaries, deep LangChain and LangSmith integration, or complex cyclic behavior coded directly. Choose an alternative when non-engineers must inspect workflow logic, broader business integrations matter more than graph primitives, typed application code is preferable, or retrieval and document pipelines dominate the system. + +## How to Migrate From LangGraph to Sim + +Sim provides the clearest migration path for teams willing to translate graph nodes into visible workflow steps rather than reproduce every LangGraph abstraction one-for-one. + +1. **Inventory the graph.** List every node, edge, branch, tool, state field, checkpoint, interrupt, retry rule, and external side effect. +2. **Separate orchestration state from business data.** Identify routing values and data that belongs in a durable system of record. +3. **Map nodes to Sim blocks or subflows.** Convert model calls, integrations, transformations, and decisions into explicit steps. +4. **Map conditional edges to Conditions.** Preserve each predicate and define default and failure routes. +5. **Rebuild human interrupts with Human in the Loop.** Collect the decision, then route approval or rejection through a downstream Condition. +6. **Choose a persistence strategy.** Keep durable customer, task, or case state in an external system rather than treating transient workflow values as LangGraph checkpoints. +7. **Recreate retries and idempotency.** Protect message sends and record updates so retried runs do not duplicate side effects. +8. **Test representative traces side by side.** Compare outputs, branches, approvals, failures, latency, and model cost. +9. **Move traffic gradually.** Start with internal users or a small share of runs and retain a rollback path. + +A Sim migration should preserve observable behavior, not merely produce a workflow that resembles the old graph. Cyclic graphs, custom reducers, and checkpoint-dependent applications may be better left in LangGraph or moved incrementally behind an API. + +## How to Migrate From LangGraph to Another Code-First Framework + +PydanticAI, CrewAI, Semantic Kernel, AutoGen, OpenAI Agents SDK, LlamaIndex, and Haystack require teams to redesign LangGraph-specific state behavior around each framework's native abstractions. + +1. Freeze representative LangGraph traces and expected outputs as regression fixtures. +2. Extract model calls, tools, schemas, and business logic from graph-node wrappers. +3. Replace LangGraph state with typed state, sessions, events, messages, or pipeline inputs. +4. Rebuild conditional edges as explicit application control flow. +5. Choose external persistence for state that must survive process restarts. +6. Reimplement interrupts and approvals as authenticated resume operations. +7. Add tracing, evaluations, logs, and failure alerts before cutover. +8. Run both implementations against the same cases and compare behavior. +9. Migrate incrementally when the old graph contains cycles, parallel branches, or long-running checkpoints. + +The highest-risk migration mistake is assuming similarly named concepts have identical persistence or retry semantics. Teams should test crash recovery, duplicate delivery, concurrent updates, and resumed approvals explicitly. + +## Which LangGraph Alternative Should You Choose? + +Sim should be the default LangGraph alternative for visual orchestration, but the final choice should follow the application's dominant requirement: PydanticAI for typed Python agents, CrewAI for role-based teams, Semantic Kernel for Microsoft-oriented plugins, AutoGen for conversational experiments, OpenAI Agents SDK for compact OpenAI-centered applications, LlamaIndex or Haystack for retrieval, n8n for integration-heavy automation, and LangGraph for explicit graph state and checkpoints. + ## Conclusion LangGraph is a strong tool for a specific set of problems. If your team requires graph-level control over state transitions with auditable execution paths, keep using it. The alternatives exist because most teams building AI agent workflows don't need that level of control, and the engineering cost of building everything LangGraph doesn't provide is higher than the cost of the framework itself. From 8524cb9057fb48e0786634190617f39083ee0d80 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 16:45:26 -0700 Subject: [PATCH 19/68] docs(library): update dify-alternatives (#8641) Co-authored-by: Sim Pi Agent --- .../library/dify-alternatives/index.mdx | 257 +++++++++++++++--- 1 file changed, 226 insertions(+), 31 deletions(-) diff --git a/apps/sim/content/library/dify-alternatives/index.mdx b/apps/sim/content/library/dify-alternatives/index.mdx index 98657d20764..07a017dc3aa 100644 --- a/apps/sim/content/library/dify-alternatives/index.mdx +++ b/apps/sim/content/library/dify-alternatives/index.mdx @@ -3,10 +3,10 @@ slug: dify-alternatives title: 'Best Dify Alternatives in 2026: Open-Source and Self-Hosted Options' description: 'Five Dify alternatives ranked for 2026 (Sim, n8n, LangChain and LangGraph, RAGFlow, and Langflow) on license, self-hosting, workflow depth, MCP support, and pricing.' date: 2026-08-27 -updated: 2026-10-01 +updated: 2026-10-05 authors: - andrew -readingTime: 15 +readingTime: 26 tags: [AI Agents, Workflow Automation, Open Source, RAG, Sim] ogImage: /library/dify-alternatives/cover.jpg draft: false @@ -23,11 +23,55 @@ faq: a: "Yes. Sim, n8n, LangChain and LangGraph, RAGFlow, and Langflow all provide self-hosting paths, but their licenses, infrastructure requirements, and included product features differ." - q: "What replaced Flowise in this list?" a: "RAGFlow replaced Flowise because RAGFlow is actively maintained, Apache 2.0 licensed, self-hostable, and focused on document RAG and agent applications; Flowise is no longer maintained, reaches end of life on August 31, 2026, and has an archived repository." + - q: "What is the best Dify alternative?" + a: "Sim is the best overall Dify alternative for teams that want visual AI-agent orchestration, self-hosting, broad model choice, and production controls in one workspace. LangGraph is stronger for code-first agent systems, n8n is stronger for integration-heavy automation, and Flowise or Langflow may suit visual RAG projects." + - q: "Is Sim open source?" + a: "Sim’s core is open source under the Apache License 2.0, while code in apps/sim/ee uses the separate Sim Enterprise License. Production use of Sim’s enterprise code requires an active Sim Enterprise subscription." + - q: "Can Sim be self-hosted?" + a: "Sim can be self-hosted, and any self-hosted Sim deployment can connect to supported local-model endpoints such as Ollama, vLLM, LM Studio, or LiteLLM. Enterprise features in apps/sim/ee have separate production licensing terms." + - q: "Can Dify be self-hosted?" + a: "Dify can be self-hosted using its official deployment options. Buyers should evaluate infrastructure ownership separately from Dify’s license conditions, cloud pricing, and operational requirements." + - q: "Is n8n a good alternative to Dify?" + a: "n8n is a good Dify alternative when SaaS integrations and deterministic business-process automation matter more than a RAG-first application experience. n8n is self-hostable under the source-available Sustainable Use License, which is not OSI-approved." + - q: "Is LangGraph a good alternative to Dify?" + a: "LangGraph is a good Dify alternative for developers who need code-level control over stateful agents, loops, persistence, recovery, and testing. LangGraph requires more engineering ownership than an all-in-one visual workspace." + - q: "Is Flowise a good alternative to Dify?" + a: "Flowise can still inform evaluations of existing visual LLM chains and RAG applications, but its maintainers announced the project’s wind-down and August 31, 2026 end of life. New projects should account for the archived repository and lack of ongoing maintenance." + - q: "Is Langflow a good alternative to Dify?" + a: "Langflow is a good Dify alternative for Python-oriented teams that want visual AI components and self-hosting. Langflow is especially suitable for prototyping retrieval, prompt, model, and tool interactions." + - q: "Is Haystack a good alternative to Dify?" + a: "Haystack is a good Dify alternative for engineering teams building custom retrieval, search, and document-processing pipelines. Haystack provides code-level flexibility but requires teams to assemble more of the surrounding application and operational stack." + - q: "Which Dify alternative is best for RAG?" + a: "Haystack is the strongest code-first Dify alternative for retrieval pipelines, while RAGFlow and Langflow are strong specialist options and Sim is best when RAG must trigger broader operational workflows. Dify itself remains a strong RAG-focused product." + - q: "Which Dify alternative is best for workflow orchestration?" + a: "Sim is the best visual Dify alternative for multistep workflow orchestration, while LangGraph is the strongest code-first option. n8n is particularly strong when orchestration depends on a large set of SaaS integrations." + - q: "Which Dify alternative supports local models?" + a: "Sim supports local models on any self-hosted deployment through supported endpoints including Ollama, vLLM, LM Studio, and LiteLLM. LangGraph, Haystack, RAGFlow, and Langflow can also work with local models through their respective integrations or custom code." + - q: "Does Sim require Enterprise for local models?" + a: "Sim does not require Enterprise for local models because local-model connectivity is available on any self-hosted Sim deployment. Enterprise licensing applies to the separately licensed capabilities in apps/sim/ee." + - q: "Does Sim support BYOK?" + a: "Sim supports workspace BYOK on every Sim Cloud plan. Organization-level keys require Pro for Teams, Max for Teams, or Enterprise, and local models are a separate self-hosting capability." + - q: "Which Dify alternative is best for human approval workflows?" + a: "Sim is the best Dify alternative for visual human-approval workflows because Sim Human in the Loop can pause a run and resume it with submitted form fields. A downstream Condition must evaluate an approval or rejection field before the workflow continues." + - q: "Which Dify alternative is best for enterprise governance?" + a: "Sim is the strongest Dify alternative for teams that want an Apache 2.0 core with separately licensed enterprise controls including SSO, SCIM, access control, audit logs, retention, and credential groups. Production use of those enterprise capabilities requires an active subscription." + - q: "How do you migrate from Dify to Sim?" + a: "A Dify-to-Sim migration should inventory prompts, datasets, retrieval settings, tools, variables, credentials, model parameters, branches, and expected outputs before rebuilding the workflow in Sim. The migrated workflow should then be tested against the same evaluation set and failure scenarios." + - q: "Should I replace Dify with Sim?" + a: "Teams should replace Dify with Sim when they need RAG to participate in broader visual workflows involving branching, external systems, human review, flexible models, and self-hosted deployment. Teams satisfied with Dify’s RAG-first application model may not need to migrate." + - q: "What is the difference between Sim and Dify?" + a: "Sim is the open-source AI workspace for building, deploying, and managing agents across multistep workflows, while Dify is centered on LLM applications, knowledge bases, RAG, and agent experiences. Sim is generally stronger for broader orchestration, while Dify remains strong for RAG-first applications." + - q: "What is the difference between Dify and n8n?" + a: "Dify is centered on LLM applications and RAG, while n8n is centered on integration-heavy workflow automation that can include AI steps. n8n is self-hostable but uses the source-available Sustainable Use License rather than an OSI-approved open-source license." + - q: "How much do Dify alternatives cost?" + a: "Dify alternatives use different billing units, including cloud plans, model usage, workflow executions, managed deployment usage, and self-hosted infrastructure costs. Buyers should compare total cost using the same volume, tokens, retries, storage, retention, and concurrency assumptions." + - q: "What should I test before choosing a Dify alternative?" + a: "Teams should test a Dify alternative with one representative production workflow covering retrieval quality, tool calls, model choice, failure handling, debugging, permissions, deployment, latency, and total cost. A feature checklist alone cannot establish production fit." --- ## TL;DR -**Sim is the best overall Dify alternative in 2026 for teams that need broader workflow automation, tool-using agents, a permissive Apache 2.0 core license, and MCP support in both directions.** Choose n8n for integration-heavy technical automation, LangChain and LangGraph for code-first agent control, RAGFlow for document-heavy retrieval, and Langflow for Python-based visual LLM pipelines. +**Sim is the best overall Dify alternative in 2026 for teams that need broader workflow automation, tool-using agents, a permissive Apache 2.0 core license with [separately licensed enterprise code](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), and MCP support in both directions.** Choose n8n for integration-heavy technical automation, LangChain and LangGraph for code-first agent control, RAGFlow for document-heavy retrieval, and Langflow for Python-based visual LLM pipelines. Dify remains a strong choice when prompt iteration, knowledge retrieval, and packaged LLM applications define most of the workload. Look beyond Dify when you need wider business-system automation or a standard permissive license without Dify's added multi-tenant and branding conditions. @@ -35,6 +79,8 @@ This page ranks the wider field. If you have already narrowed the choice to Sim ## Quick answer +**Sim is the best overall Dify alternative, with specialist choices depending on whether a team prioritizes integrations, code, or retrieval.** + - **Best Dify alternative overall:** [Sim](https://www.sim.ai) - **Best for broad technical automation:** [n8n](https://n8n.io) - **Best for code-level agent control:** [LangChain and LangGraph](https://github.com/langchain-ai/langgraph) @@ -44,64 +90,73 @@ This page ranks the wider field. If you have already narrowed the choice to Sim ## How we ranked the best Dify alternatives -**This ranking evaluates each platform against the same five buyer-relevant criteria: license, workflow depth, agent and MCP capability, deployment control, and commercial cost.** We ranked broader automation and agent-building capability first because this page is for buyers who have already identified a reason to look beyond Dify's LLM-app and RAG focus. +**The Dify alternatives ranking evaluates each platform against the same five buyer-relevant criteria: license, workflow depth, agent and MCP capability, deployment control, and commercial cost.** We ranked broader automation and agent-building capability first because this page is for buyers who have already identified a reason to look beyond Dify's LLM-app and RAG focus. - **Author:** Andrew Caslow - **Affiliation:** Sim - **Review basis:** Official vendor documentation, pricing pages, license files, product documentation, and vendor-maintained repositories -- **Facts checked:** August 26, 2026 +- **Facts checked:** October 5, 2026 - **Ranking rule:** The highest-ranked product must provide the strongest overall fit across the published criteria, not merely the closest feature match to Dify Prices, plan limits, product status, and license terms can change. All changing claims below were checked against primary vendor sources on the fact-check date. ## What should you look for in a Dify alternative? -**The right Dify alternative should solve the specific limitation that caused you to leave Dify without creating a larger licensing, deployment, or operational problem.** Evaluate these five criteria before choosing a platform. +**A Dify alternative should solve the specific limitation that caused you to leave Dify without creating a larger licensing, deployment, or operational problem.** Evaluate these five criteria before choosing a platform. ### License and commercial rights -**A standard permissive license creates fewer product-specific restrictions than Dify's modified Apache terms.** Dify's [license](https://github.com/langgenius/dify/blob/main/LICENSE) requires written authorization to operate a multi-tenant service from its source and prevents removal or modification of the Dify console logo and copyright notices. Compare those conditions with Apache 2.0, MIT, or a source-available fair-code license based on your intended use. +**Dify alternatives with a standard permissive license create fewer product-specific restrictions than Dify's modified Apache terms.** Dify's [license](https://github.com/langgenius/dify/blob/main/LICENSE) requires written authorization to operate a multi-tenant service from its source and prevents removal or modification of the Dify console logo and copyright notices. Compare those conditions with Apache 2.0, MIT, or a source-available fair-code license based on your intended use. ### Workflow depth -**Workflow depth measures whether a platform can coordinate APIs, business tools, structured data, branching, schedules, and event-driven processes in addition to model calls and retrieval.** Dify may remain the better fit when the application is primarily a prompt, knowledge base, or chatbot experience. +**A Dify alternative's workflow depth measures whether it can coordinate APIs, business tools, structured data, branching, schedules, and event-driven processes in addition to model calls and retrieval.** Dify may remain the better fit when the application is primarily a prompt, knowledge base, or chatbot experience. ### Agent building and MCP -**Strong agent tooling should support tool use, controlled execution, and interoperable deployment.** Model Context Protocol support matters when workflows must consume external tools, publish capabilities to other AI applications, or do both. +**A Dify alternative's agent tooling should support tool use, controlled execution, and interoperable deployment.** Model Context Protocol support matters when workflows must consume external tools, publish capabilities to other AI applications, or do both. ### Deployment and self-hosting -**A credible self-hosting path should include documented infrastructure requirements and a clear distinction between open-source, cloud, and enterprise features.** Also confirm whether production workflows can be exposed through APIs, chat interfaces, or MCP tools. +**A Dify alternative's self-hosting path should include documented infrastructure requirements and a clear distinction between open-source, cloud, and enterprise features.** Also confirm whether production workflows can be exposed through APIs, chat interfaces, or MCP tools. ### Pricing and operating cost -**Compare the billing unit, not just the headline subscription.** Credits, workflow executions, seats, traces, compute units, model tokens, and self-hosting infrastructure produce different cost curves. +**Dify alternatives should be compared by billing unit, not just headline subscription.** Credits, workflow executions, seats, traces, compute units, model tokens, and self-hosting infrastructure produce different cost curves. ## Key facts at a glance -- **Sim:** Sim's core is [Apache 2.0 open source](https://docs.sim.ai/introduction), supports documented [self-hosting](https://docs.sim.ai/platform/self-hosting), connects to [1,000+ integrations](https://docs.sim.ai/introduction), works as an [MCP client](https://docs.sim.ai/agents/mcp) and [server](https://docs.sim.ai/workflows/deployment/mcp), and deploys workflows as [APIs, chat pages, or MCP tools](https://docs.sim.ai/workflows/deployment). +**Sim, Dify, n8n, LangGraph, RAGFlow, Langflow, Flowise, and Haystack differ materially in license, deployment, and billing model.** + +- **Sim:** Sim's core is [Apache 2.0 open source](https://github.com/simstudioai/sim/blob/main/LICENSE), with [`apps/sim/ee` under the separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); Sim supports documented [self-hosting](https://docs.sim.ai/platform/self-hosting), connects to [1,000+ integrations](https://docs.sim.ai/introduction), works as an [MCP client](https://docs.sim.ai/agents/mcp) and [server](https://docs.sim.ai/workflows/deployment/mcp), and deploys workflows as [APIs, chat pages, or MCP tools](https://docs.sim.ai/workflows/deployment). - **n8n:** n8n is [source-available under Sustainable Use License Version 1.0](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) and is strongest for integration-heavy technical automation [billed by completed workflow executions](https://n8n.io/pricing/) on its cloud plans. - **LangChain and LangGraph:** LangChain and LangGraph are [MIT-licensed code frameworks](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) for developers who want explicit control over agent state, branching, retries, persistence, and human review. - **RAGFlow:** RAGFlow is an actively maintained [Apache 2.0 RAG engine and agent platform](https://github.com/infiniflow/ragflow/blob/main/LICENSE) with [Docker-based self-hosting](https://ragflow.io/docs/) and [public cloud tiers](https://ragflow.io/). - **Langflow:** Langflow is an [MIT-licensed visual Python platform](https://github.com/langflow-ai/langflow/blob/main/LICENSE) for LLM and RAG pipelines, and [DataStax is now presented by IBM as an IBM company](https://www.ibm.com/products/datastax). +- **Dify:** Dify is self-hostable through its [official deployment options](https://docs.dify.ai/en/self-host/deploy/overview) under a [modified Apache 2.0 license](https://github.com/langgenius/dify/blob/main/LICENSE), while its cloud service publishes plan-specific allowances on the [Dify pricing page](https://dify.ai/pricing). +- **Flowise:** Flowise's community code is under Apache 2.0 with separately licensed enterprise files, according to its [license](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md), but the maintainers announced the project's wind-down and August 31, 2026 end of life in the [official repository discussion](https://github.com/FlowiseAI/Flowise/discussions/6727). +- **Haystack:** Haystack is an [Apache 2.0](https://github.com/deepset-ai/haystack/blob/main/LICENSE) code-first framework whose [pipeline model](https://docs.haystack.deepset.ai/docs/pipelines) connects retrieval, preprocessing, generation, routing, and custom components. ## 1. Sim +**Sim is the best overall choice for visual production agents and multistep operational workflows.** + ### Best for -**Best for:** Teams that want visual workflow automation and AI agents in one workspace with a permissively licensed core. +**Sim is best for teams that want visual workflow automation and AI agents in one workspace with a permissively licensed core.** ### What it is [Sim](https://www.sim.ai) combines deterministic workflow steps and model-driven agents in the same visual graph. Teams can connect [1,000+ integrations](https://docs.sim.ai/introduction) and keep predictable operations separate from decisions that require model judgment. -Sim's core is [Apache 2.0 open source](https://docs.sim.ai/introduction) and has documented [Docker and Kubernetes self-hosting](https://docs.sim.ai/platform/self-hosting). Features in `apps/sim/ee`, such as SSO, SCIM, access control, audit logs, and white-labeling, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. It supports MCP in both directions: agents can [use tools from external MCP servers](https://docs.sim.ai/agents/mcp), and completed workflows can be [deployed as MCP tools](https://docs.sim.ai/workflows/deployment/mcp). A workflow can also be deployed as a [REST API or hosted chat page](https://docs.sim.ai/workflows/deployment). +Sim's core is [Apache 2.0 open source](https://github.com/simstudioai/sim/blob/main/LICENSE) and has documented [Docker and Kubernetes self-hosting](https://docs.sim.ai/platform/self-hosting), while [`apps/sim/ee` is separately licensed](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Features in `apps/sim/ee`, such as SSO, SCIM, access control, audit logs, and white-labeling, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. It supports MCP in both directions: agents can [use tools from external MCP servers](https://docs.sim.ai/agents/mcp), and completed workflows can be [deployed as MCP tools](https://docs.sim.ai/workflows/deployment/mcp). A workflow can also be deployed as a [REST API or hosted chat page](https://docs.sim.ai/workflows/deployment). This makes Sim a broader automation alternative rather than a clone of Dify. Dify remains more specialized around prompts, retrieval, and packaged LLM applications; Sim is designed for workflows that must coordinate AI decisions with business systems and repeatable operational logic. ### Pros +**Sim combines a permissively licensed core with visual orchestration, integrations, and multiple deployment surfaces.** + - [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) provides standard permissive rights for use, modification, redistribution, and self-hosting of the core. - One graph can combine fixed workflow logic with tool-using AI agents. - Sim [publishes support for 1,000+ integrations](https://docs.sim.ai/introduction) across business and developer services. @@ -111,6 +166,8 @@ This makes Sim a broader automation alternative rather than a clone of Dify. Dif ### Cons +**Sim requires buyers to evaluate usage-based cost, enterprise licensing, and fit for retrieval-specialist workloads.** + - [Credit usage varies](https://docs.sim.ai/platform/costs) with workflow design and non-model services, so teams should monitor consumption as volume grows. - [Enterprise administration features](https://www.sim.ai/pricing) such as SSO and access control require a custom Enterprise plan. - Teams focused almost entirely on document retrieval may prefer Dify's packaged RAG workflow or RAGFlow's document-first architecture. @@ -118,13 +175,15 @@ This makes Sim a broader automation alternative rather than a clone of Dify. Dif ### Pricing -**As of August 2026, [Sim pricing](https://www.sim.ai/pricing) is Free at $0 with 1,000 one-time credits; Pro at $25 per user per month with 6,000 monthly credits and a 2,000-credit weekly refresh; Max at $100 per user per month with 25,000 monthly credits and a 4,000-credit weekly refresh; and Enterprise at custom pricing with custom credits.** The pricing page also offers a 15% annual-billing discount. Model-provider charges, local infrastructure, and other metered services should be considered separately from plan credits. +**As of October 2026, [Sim pricing](https://www.sim.ai/pricing) is Free at $0 with 1,000 one-time credits; Pro at $25 per user per month with 6,000 monthly credits and a 2,000-credit weekly refresh; Max at $100 per user per month with 25,000 monthly credits and a 4,000-credit weekly refresh; and Enterprise at custom pricing with custom credits.** The pricing page also offers a 15% annual-billing discount. Model-provider charges, local infrastructure, and other metered services should be considered separately from plan credits. ## 2. n8n +**n8n is the strongest ranked choice for integration-heavy technical automation.** + ### Best for -**Best for:** Engineering-led teams that need broad API, database, and business-tool automation and can work within a source-available fair-code license. +**n8n is best for engineering-led teams that need broad API, database, and business-tool automation and can work within a source-available fair-code license.** ### What it is @@ -136,6 +195,8 @@ Compared with Dify, n8n provides broader deterministic automation and a larger e ### Pros +**n8n offers broad technical and business automation beyond chat and RAG applications.** + - Broad technical and business automation reaches beyond chat and RAG applications. - The [Community Edition](https://docs.n8n.io/hosting/) provides a self-hosting path for permitted use under the Sustainable Use License. - [JavaScript and Python support](https://docs.n8n.io/code/code-node/) lets engineers implement logic beyond standard nodes. @@ -144,6 +205,8 @@ Compared with Dify, n8n provides broader deterministic automation and a larger e ### Cons +**n8n requires buyers to accept source-available license limits and the operating demands of self-hosting.** + - [Sustainable Use License Version 1.0](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is not OSI-approved and can be unsuitable for hosted, resale, or white-label product models. - Self-hosting requires the operator to manage upgrades, security, backups, scaling, and reliability. - Large visual workflows can become difficult to inspect and maintain without governance conventions. @@ -151,13 +214,15 @@ Compared with Dify, n8n provides broader deterministic automation and a larger e ### Pricing -**As of August 2026, [n8n pricing](https://n8n.io/pricing/) lists Starter at €20 per month for 2,500 monthly workflow executions, Pro at €50 per month for 10,000 monthly executions, and Business at €667 per month for 40,000 monthly executions, with those prices billed annually.** Enterprise uses custom pricing. Business is a self-hosted tier, while Enterprise can support cloud or self-hosted deployment. The free Community Edition can be self-hosted under n8n's license, but infrastructure and maintenance costs remain. +**As of October 2026, [n8n pricing](https://n8n.io/pricing/) lists Starter at €20 per month, Pro at €50 per month, and Business at €667 per month when billed annually; pricing is based on monthly workflow executions rather than step count.** Enterprise uses custom pricing. Business is a self-hosted tier, while Enterprise can support cloud or self-hosted deployment. The free Community Edition can be self-hosted under n8n's license, but infrastructure and maintenance costs remain. ## 3. LangChain and LangGraph +**LangChain and LangGraph are the strongest ranked choice for code-first control of agent state and execution.** + ### Best for -**Best for:** Engineering teams that want code-level control over agent state, branching, retries, persistence, and human approval. +**LangChain and LangGraph are best for engineering teams that want code-level control over agent state, branching, retries, persistence, and human approval.** ### What it is @@ -169,6 +234,8 @@ Unlike Dify's visual application builder, these frameworks require code and leav ### Pros +**LangChain and LangGraph provide permissive licensing and explicit, code-defined execution control.** + - [MIT licensing](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) provides standard permissive rights for the core LangChain and LangGraph frameworks. - [LangGraph's explicit state model](https://langchain-ai.github.io/langgraph/concepts/low_level/) supports controlled branching, persistence, and resumable execution. - [Human-in-the-loop interrupts](https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/) can be built into the application flow. @@ -177,6 +244,8 @@ Unlike Dify's visual application builder, these frameworks require code and leav ### Cons +**LangChain and LangGraph require more engineering ownership than a shared visual workspace.** + - Teams must write and maintain code rather than editing a shared visual application. - Dify provides more packaged prompt, knowledge-base, and RAG application features out of the box. - Production use may require separate choices for ingestion, retrieval, evaluation, tracing, deployment, authentication, and user experience. @@ -184,13 +253,15 @@ Unlike Dify's visual application builder, these frameworks require code and leav ### Pricing -**As of August 2026, the core LangChain and LangGraph frameworks are free to use under their MIT licenses, while [LangSmith pricing](https://www.langchain.com/pricing) lists a Developer plan at $0 for one seat and a Plus plan at $39 per seat per month.** Plus includes 10,000 base traces and one free Serverless Small deployment; additional usage is metered. LangSmith lists LangChain Usage Units at $1.50 per LCU and LangSmith Usage Units at $1.00 per LSU. Enterprise pricing is custom. Model, storage, database, and infrastructure charges remain separate. +**As of October 2026, the core LangChain and LangGraph frameworks are free to use under their MIT licenses, while [LangSmith pricing](https://www.langchain.com/pricing) lists a Developer plan at $0 for one seat and a Plus plan at $39 per seat per month.** Developer includes up to 5,000 base traces per month and Plus includes up to 10,000 before additional usage is metered. LangSmith lists LangChain Usage Units at $1.50 per LCU and LangSmith Usage Units at $1.00 per LSU. Enterprise pricing is custom. Model, storage, database, and infrastructure charges remain separate. ## 4. RAGFlow +**RAGFlow is the strongest ranked specialist for document-heavy retrieval and grounded answers.** + ### Best for -**Best for:** Teams building document-heavy RAG applications that need deep parsing, retrieval, citations, agents, and an Apache 2.0 self-hosting path. +**RAGFlow is best for teams building document-heavy RAG applications that need deep parsing, retrieval, citations, agents, and an Apache 2.0 self-hosting path.** ### What it is @@ -202,6 +273,8 @@ The project provides official [Docker Compose self-hosting documentation](https: ### Pros +**RAGFlow combines permissive licensing with document parsing, retrieval, reranking, and citations.** + - [Apache 2.0](https://github.com/infiniflow/ragflow/blob/main/LICENSE) provides a standard permissive license for the open-source project. - [Document parsing, chunking, hybrid retrieval, reranking, and citations](https://ragflow.io/docs/) are central product capabilities. - [Official Docker Compose documentation](https://ragflow.io/docs/) supports self-hosting. @@ -210,6 +283,8 @@ The project provides official [Docker Compose self-hosting documentation](https: ### Cons +**RAGFlow asks operators to support a comparatively substantial document-processing stack.** + - The [documented self-hosting minimum](https://ragflow.io/docs/) is heavier than a small application runtime. - RAGFlow is less suited than Sim or n8n to broad operational automation across many business tools. - The [vendor states](https://ragflow.io/docs/) that cloud and local open-source experiences are not identical, including differences in API availability and Enterprise capabilities. @@ -217,13 +292,15 @@ The project provides official [Docker Compose self-hosting documentation](https: ### Pricing -**As of August 2026, [RAGFlow pricing](https://ragflow.io/) lists Free at $0 per month with five apps, one team member, 0.1 GB of dataset storage, and 500 monthly credits; Starter at $29 per month with 50 apps, five members, 5 GB, and 5,000 monthly credits; Pro at $129 per month with unlimited apps, 20 members, 50 GB, and 20,000 monthly credits; and Enterprise at custom pricing.** The public page showed promotional strike-through reference prices for Starter and Pro, so buyers should confirm the current checkout price. Self-hosting still requires infrastructure, model, storage, and operational spend. +**As of October 2026, [RAGFlow pricing](https://ragflow.io/pricing/details) uses a base subscription, add-on packs, and enterprise customization, with Free, Small team, Medium team, and Enterprise options.** The vendor describes billing as beta and says its limited-time billing currently applies to PDF parsing through DeepDoc. Self-hosting still requires infrastructure, model, storage, and operational spend. ## 5. Langflow +**Langflow is the strongest ranked visual choice for Python-oriented component development.** + ### Best for -**Best for:** Python-focused teams that want a visual builder for LLM and RAG pipelines while retaining the ability to edit component code. +**Langflow is best for Python-focused teams that want a visual builder for LLM and RAG pipelines while retaining the ability to edit component code.** ### What it is @@ -235,6 +312,8 @@ Compared with Dify, Langflow gives Python teams more direct component-level cust ### Pros +**Langflow combines permissive licensing, visual composition, and editable Python components.** + - [MIT licensing](https://github.com/langflow-ai/langflow/blob/main/LICENSE) provides standard permissive rights for the open-source project. - [Python developers can modify component code](https://docs.langflow.org/components-custom-components) and behavior directly. - A visual graph makes model, prompt, tool, and retrieval relationships easier to inspect than code alone. @@ -243,6 +322,8 @@ Compared with Dify, Langflow gives Python teams more direct component-level cust ### Cons +**Langflow still leaves teams responsible for the surrounding user experience and production controls.** + - Teams generally need to build or connect the end-user interface separately. - Custom component code runs inside the deployment environment and requires security and stability controls. - Dify offers a more packaged RAG application and experiment-management experience. @@ -250,42 +331,105 @@ Compared with Dify, Langflow gives Python teams more direct component-level cust ### Pricing -**As of August 2026, the [Langflow open-source project](https://github.com/langflow-ai/langflow) can be self-hosted without a software license fee under the MIT License; infrastructure, storage, model, and maintenance costs still apply.** A current public managed-service price was not verified from a primary vendor pricing page during this review, so buyers should confirm any hosted Langflow or IBM service pricing directly with the vendor before purchase. +**As of October 2026, the [Langflow open-source project](https://github.com/langflow-ai/langflow) can be self-hosted without a software license fee under the MIT License; infrastructure, storage, model, and maintenance costs still apply.** + +> **Flowise status:** According to its [official repository](https://github.com/FlowiseAI/Flowise), Flowise is no longer maintained, reaches end of life on August 31, 2026, and has an archived repository; its community code remains available under the terms in the repository's license. + +## Why are teams looking for alternatives to Dify? + +**Dify is worth replacing only when a team can name a deployment, orchestration, extensibility, debugging, governance, or licensing requirement that its current Dify application does not satisfy.** Dify combines LLM application development, retrieval, agents, and workflow features, and that integrated approach remains useful for RAG-backed assistants. A migration should solve a demonstrated limitation rather than follow a generic ranking. + +The most common reasons to evaluate alternatives are: + +- **Workflow orchestration:** The application needs branching, reusable logic, human review, tool calls, or long-running multistep execution around retrieval. +- **Self-hosting:** The team needs direct control over infrastructure, data residency, model endpoints, or operating cost. Dify itself has an [official self-hosting path](https://docs.dify.ai/en/self-host/deploy/overview), so this criterion is about operational fit rather than mere availability. +- **Model choice:** The architecture must work across hosted providers or local models without depending on one model vendor. +- **Debugging:** Developers need run histories, traces, intermediate outputs, evaluations, or code-level instrumentation suited to their operating model. +- **Governance:** Administrators need identity management, access boundaries, auditability, retention policies, or organization-wide credential management. +- **Licensing:** The intended use requires a standard permissive license instead of Dify's [modified Apache terms](https://github.com/langgenius/dify/blob/main/LICENSE). + +## How do Dify alternatives compare on RAG? + +**Dify, RAGFlow, Langflow, Haystack, LangGraph, and Sim can all participate in RAG systems, but they organize retrieval around different product goals.** Dify is a natural fit when document ingestion, knowledge bases, retrieval, and an assistant interface are the center of the application. RAGFlow emphasizes document parsing, chunking, hybrid retrieval, reranking, and citations. Langflow gives Python-oriented teams a visual component graph, while Haystack exposes retrieval as explicit code-first [pipeline components](https://docs.haystack.deepset.ai/docs/components). + +LangGraph lets developers model retrieval inside stateful application code. Sim is a better fit when retrieval is one step in a larger operational process, such as retrieving policy context, collecting structured data, requesting human input, updating a business system, and notifying a team. The correct test is not whether a product has a retriever, but whether it can reproduce the current application's ingestion, metadata filtering, ranking, citation, latency, and failure behavior. + +Flowise historically offered a visual environment for LLM chains and RAG, and its [deployment documentation](https://docs.flowiseai.com/configuration/deployment) remains useful to teams maintaining an existing installation. It is not a recommended foundation for a new migration because its maintainers announced the project's wind-down and end of life. + +## Which Dify alternative has the best workflow orchestration? + +**Sim has the best visual workflow orchestration for most teams replacing Dify, while LangGraph is the strongest choice for developers who want orchestration expressed in code.** Sim's workflow builder combines multistep agents, branching, integrations, reusable logic, and human-in-the-loop patterns. Guardrails reports whether a check passed or failed, so a downstream Condition must route the workflow to stop or continue. Human in the Loop pauses a run and resumes it with submitted form fields; an approval or rejection is a field that a downstream Condition must evaluate. + +LangGraph gives developers explicit control over state, nodes, edges, loops, persistence, and recovery. n8n is strongest when a process spans many SaaS applications and deterministic automation steps; its [advanced AI documentation](https://docs.n8n.io/advanced-ai/) explains how AI nodes fit into those workflows. RAGFlow and Langflow are easier to evaluate when the graph is primarily composed of model, prompt, retrieval, memory, and tool components. + +## Which Dify alternatives can be self-hosted? + +**Sim, Dify, n8n, RAGFlow, Langflow, LangGraph, Haystack, and legacy Flowise deployments can run in infrastructure controlled by the user, but their licenses and operational boundaries are not equivalent.** An open-source library embedded in an application is different from a complete collaborative workspace. LangGraph and Haystack provide substantial architectural control, but engineering teams assemble more of the interface, authentication, deployment, and governance stack themselves. + +Sim's core is Apache 2.0, while [Sim Enterprise-licensed code](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) includes SSO, SCIM, access control, access requests, audit logs, white-labeling, data retention, data drains, workspace forking, session policies, custom blocks, and credential groups. Enterprise code is free for development, testing, and internal non-production use; production use requires an active Sim Enterprise subscription, and modification and redistribution are not permitted. -> **Flowise status:** According to its [official repository](https://github.com/FlowiseAI/Flowise), Flowise is no longer maintained, reaches end of life on August 31, 2026, and has an archived repository; its Apache 2.0 code remains available to fork. +n8n is self-hostable through its [documented hosting options](https://docs.n8n.io/deploy/host-n8n), but its community code uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), a source-available license that is not OSI-approved. Langflow documents [Docker self-hosting](https://docs.langflow.org/deployment-docker), while Dify documents Docker-based deployment on infrastructure controlled by the operator. + +## Which Dify alternative supports the most model choice? + +**Sim, LangGraph, Haystack, RAGFlow, and Langflow support broad model choice, while Sim combines hosted providers with local-model support on any self-hosted Sim deployment.** Self-hosted Sim can connect to Ollama through `OLLAMA_URL` and to vLLM, LM Studio, or LiteLLM through supported base URL configuration. Local models are a self-hosting capability and do not require Enterprise. + +Sim Cloud supports workspace BYOK on every cloud plan. Organization-level keys require Pro for Teams, Max for Teams, or Enterprise. Code-first frameworks such as LangGraph and Haystack provide model flexibility through integrations and custom code, while visual products expose provider and model components in their builders. n8n can place model calls inside a larger integration workflow through provider nodes, HTTP requests, and custom code. + +## Which Dify alternative is easiest to debug? + +**LangGraph with LangSmith offers the deepest code-first debugging path, while Sim offers the clearest visual combination of workflow construction and run inspection.** Debugging quality depends on whether a team needs visual run history, distributed traces, evaluation datasets, intermediate state, or application logs. LangGraph and Haystack fit engineering teams that want code-level instrumentation; Sim, n8n, RAGFlow, and Langflow make individual runs accessible to operators who do not work directly in code. + +A proof of concept should force failed tool calls, model timeouts, malformed structured output, retrieval misses, approval delays, retries, and partial downstream updates. A platform that shows a successful model response but hides failed business actions is not providing enough evidence for production operation. + +## Which Dify alternative has the strongest governance? + +**Sim is the strongest Dify alternative for teams that want an open-source core plus separately licensed enterprise controls, while n8n is credible for organizations centered on integration governance.** Sim's enterprise code includes SSO, SCIM, access control, access requests, audit logs, data retention, data drains, workspace forking, session policies, custom blocks, and credential groups. Production use of those capabilities requires an active subscription under the Sim Enterprise License. + +Governance is an operating system rather than a checklist. Buyers should test identity provisioning, workspace boundaries, credential ownership, auditability, retention, model-key management, approval routing, incident response, and separation between development and production. Code-first frameworks can satisfy the same requirements, but the adopting team must design and maintain more of the controls. + +## How much do Dify alternatives cost? + +**Dify alternatives use different billing units, so buyers should compare total workload cost instead of only the lowest advertised monthly plan.** As of October 2026, Dify publishes cloud plans with plan-specific allowances on its [pricing page](https://dify.ai/pricing); n8n charges cloud plans primarily by workflow executions on its [pricing page](https://n8n.io/pricing/); and LangSmith separates seat and usage charges on its [pricing page](https://www.langchain.com/pricing). Sim Cloud combines plan access with credits and BYOK, and its [cost documentation](https://docs.sim.ai/platform/costs) explains run, model, and hosted-tool charges. + +Self-hosting changes the bill rather than eliminating it. Include infrastructure, databases, vector storage, model inference, observability, backups, upgrades, incident response, and engineering ownership. A representative cost test should use the same workflow volume, model tokens, retry rate, storage footprint, retention period, concurrency, and human-review pattern on every candidate. ## Dify alternatives compared **Sim ranks first because it offers the strongest overall combination of a standard permissive core license, visual automation, agent building, integrations, MCP interoperability, and deployment options.** The table uses the same criteria for every ranked product. -| Rank | Alternative | Exact license | Hosting | Primary strength | Workflow model | Pricing (as of August 2026) | +| Rank | Alternative | Exact license | Hosting | Primary strength | Workflow model | Pricing (as of October 2026) | | ---- | ----------- | ------------- | ------- | ---------------- | -------------- | --------------------------- | | 1 | [Sim](https://www.sim.ai) | [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) for the core; [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) for `apps/sim/ee` | [Sim cloud; documented Docker and Kubernetes self-hosting](https://docs.sim.ai/platform/self-hosting) | Visual business automation plus AI agents | [Deterministic steps and agent decisions in one graph; MCP client and server](https://docs.sim.ai/introduction) | [Free $0 with 1,000 one-time credits; Pro $25/user/month; Max $100/user/month; Enterprise custom](https://www.sim.ai/pricing) | | 2 | [n8n](https://n8n.io) | [Sustainable Use License Version 1.0; source-available fair-code, not OSI-approved](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) | [n8n cloud and self-hosting under license terms](https://docs.n8n.io/hosting/) | Broad API, database, and business-tool automation | Visual node workflows with code and AI-agent steps | [Starter €20/month, Pro €50/month, Business €667/month billed annually; Enterprise custom](https://n8n.io/pricing/) | | 3 | [LangChain and LangGraph](https://github.com/langchain-ai/langgraph) | [MIT License for the core frameworks](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) | Self-managed applications; [commercial LangSmith deployment options](https://www.langchain.com/pricing) | Code-level control of agent state and execution | [Python or TypeScript graphs with explicit state, nodes, edges, and interrupts](https://langchain-ai.github.io/langgraph/concepts/low_level/) | [Core frameworks free under MIT; LangSmith Developer $0, Plus $39/seat/month](https://www.langchain.com/pricing) | -| 4 | [RAGFlow](https://ragflow.io) | [Apache License 2.0](https://github.com/infiniflow/ragflow/blob/main/LICENSE) | [Vendor cloud; Docker Compose self-hosting; Enterprise options](https://ragflow.io/) | Document parsing, retrieval, reranking, and grounded citations | RAG engine with agent and workflow capabilities | [Free $0; Starter $29/month; Pro $129/month; Enterprise custom](https://ragflow.io/) | -| 5 | [Langflow](https://www.langflow.org) | [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE) | [Self-hosting](https://docs.langflow.org/deployment-caddyfile); vendor-associated managed options | Visual Python LLM and RAG pipelines | Component graph with editable Python code | [Open-source self-hosting has no software license fee](https://github.com/langflow-ai/langflow); managed pricing requires vendor confirmation | +| 4 | [RAGFlow](https://ragflow.io) | [Apache License 2.0](https://github.com/infiniflow/ragflow/blob/main/LICENSE) | [Vendor cloud; Docker Compose self-hosting; Enterprise options](https://ragflow.io/) | Document parsing, retrieval, reranking, and grounded citations | RAG engine with agent and workflow capabilities | [Free, Small team, Medium team, and Enterprise options; beta billing currently applies to DeepDoc PDF parsing](https://ragflow.io/pricing/details) | +| 5 | [Langflow](https://www.langflow.org) | [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE) | [Self-hosting](https://docs.langflow.org/deployment-caddyfile); vendor-associated managed options | Visual Python LLM and RAG pipelines | Component graph with editable Python code | [Open-source self-hosting has no software license fee](https://github.com/langflow-ai/langflow) | +| Reference | [Flowise](https://github.com/FlowiseAI/Flowise) | [Apache 2.0 community code with separately licensed enterprise files](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) | Existing self-hosted deployments | Legacy visual LLM chains and RAG | Component graph | [Project wind-down and August 31, 2026 end of life](https://github.com/FlowiseAI/Flowise/discussions/6727) | +| Reference | [Haystack](https://github.com/deepset-ai/haystack) | [Apache License 2.0](https://github.com/deepset-ai/haystack/blob/main/LICENSE) | Self-managed applications | Code-first retrieval and document pipelines | [Directed multigraph pipelines](https://docs.haystack.deepset.ai/docs/pipelines) | Open-source framework; infrastructure and external services are separate | ## Which Dify alternative fits your team? -**Choose the product whose strongest capability matches the reason you are leaving Dify.** A platform that wins on licensing may not win on retrieval depth, and a framework that wins on agent control may require substantially more engineering. +**A Dify buyer should choose the product whose strongest capability matches the reason for leaving Dify.** A platform that wins on licensing may not win on retrieval depth, and a framework that wins on agent control may require substantially more engineering. -- **Choose Sim for broader automation plus agents.** Sim is the best fit when workflows must coordinate business tools, structured data, deterministic steps, and AI judgment. Its [Apache 2.0 core license](https://github.com/simstudioai/sim/blob/main/LICENSE), [1,000+ integrations](https://docs.sim.ai/introduction), [MCP client-and-server support](https://docs.sim.ai/agents/mcp), and [API, chat, and MCP deployment options](https://docs.sim.ai/workflows/deployment) make it the broadest option in this ranking. +- **Choose Sim for broader automation plus agents.** Sim is the best fit when workflows must coordinate business tools, structured data, deterministic steps, and AI judgment. Its [Apache 2.0 core license](https://github.com/simstudioai/sim/blob/main/LICENSE), [separately licensed enterprise code](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), [1,000+ integrations](https://docs.sim.ai/introduction), [MCP client-and-server support](https://docs.sim.ai/agents/mcp), and [API, chat, and MCP deployment options](https://docs.sim.ai/workflows/deployment) make it the broadest option in this ranking. - **Choose n8n for integration-heavy technical automation.** n8n is the stronger fit when engineering teams prioritize APIs, databases, business applications, and high-volume operational workflows over packaged RAG tooling. Confirm that [Sustainable Use License Version 1.0](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) permits the intended commercial model. - **Choose LangChain and LangGraph for full code-level control.** LangGraph is the best fit when developers need to define state, branching, retries, persistence, and human review directly in Python or TypeScript and are prepared to assemble the surrounding application stack. - **Choose RAGFlow for document-heavy retrieval.** RAGFlow is the strongest specialist when parsing, chunking, hybrid recall, reranking, citations, and grounded document answers matter more than broad business automation. - **Choose Langflow for Python visual pipelines.** Langflow is the best fit when a Python team wants visual composition but still needs to customize components and execution behavior in code. +- **Choose Haystack for code-first retrieval pipelines.** Haystack is the best fit when engineers want explicit components for indexing, retrieval, generation, routing, and document processing without adopting an all-in-one visual workspace. +- **Maintain Flowise only with an exit plan.** Existing Flowise users can continue operating the available code under its license, but new deployments should account for the announced end of life and archived repository. - **Stay with Dify for packaged LLM and RAG applications.** Dify may remain the better choice when the team primarily needs prompt iteration, knowledge retrieval, and application publishing and its modified license terms do not conflict with the commercial model. ## When should you look beyond Dify? -**Look beyond Dify when you need either broader workflow automation or license terms that better match a commercial hosting or redistribution plan.** Dify's visual builder centers on prompts, model calls, knowledge retrieval, and LLM applications. That focus is valuable, but it may not cover operational workflows that must coordinate many business systems and combine fixed logic with tool-using agents. +**Teams should look beyond Dify when they need either broader workflow automation or license terms that better match a commercial hosting or redistribution plan.** Dify's visual builder centers on prompts, model calls, knowledge retrieval, and LLM applications. That focus is valuable, but it may not cover operational workflows that must coordinate many business systems and combine fixed logic with tool-using agents. Dify's [modified Apache terms](https://github.com/langgenius/dify/blob/main/LICENSE) add two especially important conditions: source-based multi-tenant operation requires written authorization, and the console logo and copyright notices cannot be removed or modified. Those terms do not make Dify unusable; they make license fit a product-design decision that should be reviewed before deployment. ## Why does Sim lead this list? -**Sim leads because it is the only ranked platform that combines an [Apache 2.0 core license](https://github.com/simstudioai/sim/blob/main/LICENSE), visual deterministic automation, tool-using agents, [1,000+ integrations](https://docs.sim.ai/introduction), [two-way MCP support](https://docs.sim.ai/agents/mcp), and [API, chat, and MCP deployment](https://docs.sim.ai/workflows/deployment) in one workspace.** That combination directly addresses the two most common reasons to leave Dify: needing automation beyond LLM and RAG applications, and needing a standard permissive license for a broader commercial use case. +**Sim leads because it is the only ranked platform that combines an [Apache 2.0 core license](https://github.com/simstudioai/sim/blob/main/LICENSE), [separately licensed enterprise code](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), visual deterministic automation, tool-using agents, [1,000+ integrations](https://docs.sim.ai/introduction), [two-way MCP support](https://docs.sim.ai/agents/mcp), and [API, chat, and MCP deployment](https://docs.sim.ai/workflows/deployment) in one workspace.** That combination directly addresses the two most common reasons to leave Dify: needing automation beyond LLM and RAG applications, and needing a standard permissive license for a broader commercial use case. The recommendation follows the published criteria rather than claiming Sim is best for every workload. RAGFlow is stronger for deep document-centric retrieval. LangGraph gives engineers more direct control over code-defined state and execution. n8n is a strong choice for broad technical automation when its fair-code license fits. Dify remains a strong product for packaged prompt, knowledge, and RAG applications. @@ -293,8 +437,59 @@ Sim wins the overall ranking because it covers the widest buyer need without for For a focused head-to-head rather than a roundup, read [Sim vs. Dify: Open-Source AI Workspace vs. LLM App and RAG Platform](https://www.sim.ai/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform). +## When should you migrate from Dify to Sim? + +**Teams should migrate from Dify to Sim when retrieval must participate in broader visual workflows with branching, external actions, human review, flexible model selection, and self-hosted deployment.** The strongest migration cases combine several of these needs: + +1. **RAG plus business actions:** The current application retrieves context but must also update a CRM, create a ticket, call an internal API, or notify a team. +2. **Structured human input:** A regulated or high-impact action must pause for form data before continuing, with a downstream Condition evaluating any approval field. +3. **Multiple model providers:** The team wants workspace BYOK on Sim Cloud or supported local-model endpoints on self-hosted Sim. +4. **Shared visual operations:** Technical and semi-technical collaborators need to understand execution logic and inspect runs in one workspace. +5. **License alignment:** The organization wants an Apache 2.0 core and has separately evaluated the production terms for enterprise code. + +## When should you migrate from Dify to LangGraph? + +**Teams should migrate from Dify to LangGraph when developers need explicit code-level control over state, loops, persistence, recovery, and agent execution.** LangGraph is a strong fit when the agent is part of a larger software product and the engineering team is prepared to own application code, tests, deployment, infrastructure, interfaces, and operations. Its [MIT license](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) covers the framework, while LangSmith is a separate commercial service for observability and managed capabilities. + +## When should you migrate from Dify to n8n? + +**Teams should migrate from Dify to n8n when broad SaaS connectivity and deterministic business-process automation matter more than a RAG-first application experience.** n8n is useful when AI steps sit inside a larger process spanning CRMs, databases, spreadsheets, communication tools, and internal APIs. Evaluate the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) separately from technical fit because self-hostable does not mean OSI-approved open source. + +## When should you migrate from Dify to RAGFlow, Langflow, Flowise, or Haystack? + +**RAGFlow, Langflow, Flowise, and Haystack represent distinct retrieval-focused migration paths rather than interchangeable visual builders.** Choose RAGFlow when document parsing, hybrid retrieval, reranking, and grounded citations dominate the workload. Choose Langflow when a Python-oriented team wants a visual component environment and code extensibility. Choose Haystack when retrieval quality and explicit code-level pipeline composition matter more than an all-in-one workspace. + +Flowise should be treated as an incumbent migration source, not a new destination: the maintainers' [wind-down announcement](https://github.com/FlowiseAI/Flowise/discussions/6727) set August 31, 2026 as its end of life. Teams maintaining Flowise should inventory flows, custom nodes, credentials, vector stores, prompts, and application interfaces before selecting a maintained destination. + +## How do you migrate a Dify application without breaking it? + +**A Dify migration should begin with an inventory of prompts, datasets, retrieval settings, tools, variables, model parameters, credentials, branches, and expected outputs before anything is rebuilt.** Use this sequence: + +1. Export or document every prompt, system instruction, variable, model setting, and structured-output schema. +2. Inventory knowledge sources, chunking rules, embedding models, metadata, filters, reranking, and retrieval thresholds. +3. Map each Dify node to a component, block, node, or code function in the destination. +4. Move credentials through the destination's secret-management system rather than copying them into prompts or code. +5. Rebuild one representative path and compare its outputs with the current Dify application. +6. Test retrieval misses, tool failures, timeouts, malformed model output, duplicate actions, and human-review delays. +7. Run both systems against the same evaluation set and compare answer quality, latency, completion rate, and cost. +8. Cut over gradually, retain rollback capability, and monitor production traces and business outcomes. + +The destination is not equivalent until it passes the same functional, security, retrieval, and failure-mode tests as the application it replaces. + +## How should you choose a Dify alternative? + +**Teams should choose a Dify alternative by testing one representative production workflow instead of selecting from a generic feature checklist.** Choose Sim for the best overall combination of visual orchestration, self-hosting, model choice, collaboration, and enterprise controls. Choose LangGraph for stateful agents implemented by a software engineering team, n8n when integrations dominate, RAGFlow for document-heavy retrieval, Langflow for Python-oriented visual composition, and Haystack for code-first retrieval pipelines. Keep Dify when its RAG, application, and deployment model already satisfy production requirements. + +Use the [AI workflow automation buyer's checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) to score the proof of concept against common operational requirements. + +## Where can buyers check Dify alternative pricing, licenses, and deployment details? + +**Buyers should check Dify alternative claims through each project's license file, official deployment documentation, and first-party pricing page.** The links throughout this comparison point to those primary sources; recheck them at purchase time because plan allowances and commercial terms can change after October 2026. + ## Related comparisons +**Sim's related comparisons cover direct Dify, n8n, LangGraph, and open-source platform decisions.** + - **Comparing Sim and Dify directly:** [Sim vs. Dify: Open-Source AI Workspace vs. LLM App and RAG Platform](https://www.sim.ai/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform) - **Replacing n8n:** [Best n8n Alternatives](https://www.sim.ai/library/n8n-alternatives) - **Replacing LangGraph:** [Best LangGraph Alternatives](https://www.sim.ai/library/langgraph-alternatives) From 26135433de6586dfd0314460b421c35f94c8ee54 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 16:45:39 -0700 Subject: [PATCH 20/68] docs(library): update best-ai-agents-for-customer-support-automation (#8642) Co-authored-by: Sim Pi Agent --- .../index.mdx | 242 +++++++++++++++--- 1 file changed, 207 insertions(+), 35 deletions(-) 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 8129124d024..cd185f2ef75 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 @@ -3,10 +3,10 @@ slug: best-ai-agents-for-customer-support-automation title: 'Best AI Agents for Customer Support Automation' description: 'Compare six AI agent platforms for end-to-end customer support automation: feedback-to-ticket workflows, inbox management, knowledge grounding, helpdesk integrations, deployment, and self-hosting.' date: 2026-07-23 -updated: 2026-10-01 +updated: 2026-10-05 authors: - andrew -readingTime: 14 +readingTime: 23 tags: [AI Agents, Customer Support, Support Automation, Sim] ogImage: /library/best-ai-agents-for-customer-support-automation/cover.jpg draft: false @@ -17,24 +17,67 @@ faq: a: "Yes. An agent can extract the intent, sentiment, category, priority, and summary from reviews, surveys, or support channels, then create a structured ticket in a connected helpdesk." - q: "How do AI agents automate support inbox management?" a: "They read and classify incoming messages, draft grounded replies from support documentation and macros, and either send the response or escalate it for human approval according to the workflow's risk rules." + - q: "What is the best AI agent for customer support automation?" + a: "Sim is the best overall AI agent workspace for customer support automation when a team needs customizable multi-step workflows, safety checks, and human escalation across multiple systems." + - q: "What is customer support automation?" + a: "Customer support automation is the use of rules, software, and AI agents to handle bounded support work such as context retrieval, classification, response drafting, record updates, escalation, and follow-up." + - q: "What is the difference between customer support automation and ticket triage?" + a: "Customer support automation covers work across the case lifecycle, while ticket triage primarily classifies, prioritizes, and routes incoming requests." + - q: "What is the difference between an AI agent and a customer support chatbot?" + a: "An AI agent can reason across a multi-step task and take controlled actions in connected systems, while a customer support chatbot is primarily a conversational interface for answering questions or collecting information." + - q: "Can AI agents integrate with Zendesk?" + a: "Zendesk can connect AI agents to ticket and customer-support processes through approved integrations or its supported API surface, subject to the organization's authentication scopes and write permissions." + - q: "Can AI agents integrate with Intercom?" + a: "Intercom can connect AI agents to conversation and customer-support processes through approved integrations or its supported API surface, subject to the organization's authentication scopes and write permissions." + - q: "Can AI agents integrate with Freshdesk?" + a: "Freshdesk can connect AI agents to ticket and customer-support processes through approved integrations or its supported API surface, subject to the organization's authentication scopes and write permissions." + - q: "Can AI agents integrate with Salesforce Service Cloud?" + a: "Salesforce Service Cloud can connect AI agents to case, customer, knowledge, and service processes through approved Salesforce actions and APIs governed by Salesforce permissions." + - q: "How should an AI customer support agent escalate to a human?" + a: "Sim should pause the workflow at a defined risk boundary, present the case evidence and proposed action to a reviewer, and route the submitted decision through an explicit downstream condition." + - q: "Does Sim's Guardrails block automatically stop a customer support workflow?" + a: "Sim's Guardrails block reports passed or failed, so a downstream Condition must route a failed result away from sending a reply or executing an action." + - q: "Does Sim's Human in the Loop block automatically enforce approval or rejection?" + a: "Sim's Human in the Loop block pauses and resumes a run with submitted form fields, so a downstream Condition must evaluate the approval or rejection field and enforce the correct branch." + - q: "Can Sim use local models for customer support automation?" + a: "Sim can use Ollama, vLLM, LM Studio, or LiteLLM-backed local models on any self-hosted Sim deployment without requiring Sim Enterprise solely for local-model access." + - q: "Is Sim open source?" + a: "Sim's core is Apache 2.0 open source, while code in apps/sim/ee is covered by the separate Sim Enterprise License and requires an active Enterprise subscription for production use." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license." + - q: "Is n8n good for customer support automation?" + a: "n8n is a strong customer support automation option for technical teams that want self-hosted, node-based workflows and are comfortable configuring integrations, credentials, APIs, and control logic." + - q: "Is Zapier good for customer support automation?" + a: "Zapier is a strong customer support automation option for straightforward cloud app triggers and actions, especially when a team values familiar SaaS connectivity over self-hosting and deeply custom agent control." + - q: "Should a company buy the AI built into its help desk or build a custom support agent?" + a: "A company should buy its help desk's built-in AI when speed and suite-native operation matter most, but it should build a custom support agent when the process crosses systems or needs company-specific logic, models, controls, and approvals." + - q: "How do AI agents prevent hallucinated customer support answers?" + a: "AI agents reduce hallucinated support answers by using approved knowledge and customer context, requiring evidence, applying deterministic checks, and escalating when the available sources cannot support a response." + - q: "What customer support tasks should not be fully automated?" + a: "Customer support teams should not fully automate ambiguous or high-impact decisions such as unrestricted refunds, identity changes, account closures, legal threats, security incidents, safety claims, or contractual exceptions without deterministic controls or human authorization." + - q: "What metrics should teams use for customer support AI agents?" + a: "Customer support teams should measure correct resolutions, unsupported claims, policy compliance, escalation quality, human overrides, reopened cases, customer satisfaction, resolution time, cost, and integration failures." + - q: "How should a company start using an AI agent for customer support?" + a: "A company should start with offline evaluation and read-only or draft workflows, then add human-approved actions before granting limited autonomy to proven low-risk cases." --- ## TL;DR -Sim leads for teams that want an AI workspace with an open-source, self-hostable core for support automation, with runner-ups that fit specific buyer types. +Sim is the best overall open-source AI workspace for customer support automation when a team needs customizable, multi-step workflows with explicit safety checks and human escalation rather than a chatbot that only answers questions. -- **Sim** wins for self-hosting an open-source Apache 2.0 core with native Knowledge Bases and multi-surface deployment. -- **Zapier** fits teams that need the largest app catalog and standardized ease of use. +- **Sim** wins for customizable support workflows, a self-hostable Apache 2.0 core, native Knowledge Bases, and multi-surface deployment; [enterprise features are separately licensed](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). +- **n8n** works for technical teams wanting self-hosted, node-based control. +- **Zapier** fits straightforward cloud app handoffs and teams that value a broad app catalog. - **Gumloop** suits ops-led teams that want fast setup through templates. -- **n8n** works for technical teams wanting node-based control. - **Make** fits teams building visual, branching workflow logic. - **Dify** serves teams building LLM-first conversational apps. +- **Zendesk AI, Intercom Fin, Freshworks Freddy AI, and Salesforce Agentforce** can be the shortest path for teams committed to their respective support suites. -This article compares platforms across the whole support operation: ticket triage, converting customer feedback into tickets, and support inbox management. For a deep dive on triage alone, including evaluation sets, metrics, and prompt-injection safety, see the [support ticket triage guide](https://www.sim.ai/library/best-ai-agents-support-ticket-triage). +Customer support automation executes work across the case lifecycle: it can retrieve context, draft or send replies, update records, trigger fulfillment work, request approval, escalate risky cases, and record outcomes. Ticket triage is narrower, focusing on classification, priority, and routing. For that intent, see [Best AI Agents for Customer Support Ticket Triage and Routing](https://www.sim.ai/library/best-ai-agents-support-ticket-triage). ## What is the best AI agent platform for customer support automation? -Sim is the best AI agent platform for customer support automation when you want an open-source workspace you can actually control. Its core ships under the Apache 2.0 license, so you can run it as a hosted cloud product at [sim.ai](https://www.sim.ai) or self-host the core via Docker or Kubernetes without commercial-use restrictions. Enterprise features in `apps/sim/ee`, such as SSO, SCIM, access control, and audit logs, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use but requires an Enterprise subscription for production use. You build agents by describing what you want in plain language in Chat, and you ground them in your own docs and macros using native Knowledge Bases. What separates Sim from single-surface tools is the deployment step. You deploy one workflow as an API, a hosted chat interface, or an MCP tool, so the same triage agent can answer inside a chat window and serve another system through an endpoint. +Sim is the open-source AI workspace and the best overall choice for customer support automation when you want a workspace you can control. Its core ships under Apache 2.0, so you can use the core as a hosted cloud product at [sim.ai](https://www.sim.ai) or self-host it via Docker or Kubernetes without commercial-use restrictions; enterprise features in `apps/sim/ee` use the [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use but requires an Enterprise subscription for production use. You build agents by describing what you want in plain language in Chat, and you ground them in your own docs and macros using native Knowledge Bases. What separates Sim from single-surface tools is the deployment step. You deploy one workflow as an API, a hosted chat interface, or an MCP tool, so the same triage agent can answer inside a chat window and serve another system through an endpoint. That grounding matters because a support agent is only as accurate as the material it reads. A Knowledge Base of your help center articles, refund policies, and canned macros lets the agent answer from your actual rules instead of guessing. @@ -42,6 +85,32 @@ Sim also connects to the helpdesk tools your team already runs, which removes th The runner-ups each win a specific buyer. Zapier is the safe pick when you want the largest app catalog and your ops team already lives inside it. Gumloop fits ops-led teams that want fast results from a template library. n8n suits technical teams that want node-based control and a mature self-hosted engine. Make works for teams that need visual, branching workflow logic without writing much code. Dify earns a mention for teams building LLM-first conversational apps rather than broad automation. Each section below argues its case in depth, so read on for the case behind each. +## What is the best AI agent for customer support automation? + +Sim is the best overall AI agent for customizable customer support automation because its workflow builder can coordinate model calls, business rules, safety checks, human review, and downstream actions in one workflow. + +Choose according to the work the platform must control: + +- **Best overall for customizable support workflows: Sim** +- **Best for technical teams that want self-hosted node-based automation: n8n** +- **Best for straightforward cloud app handoffs: Zapier** +- **Best for a Zendesk-native deployment: Zendesk AI** +- **Best for an Intercom-native deployment: Intercom Fin** +- **Best for a Freshdesk-native deployment: Freshworks Freddy AI** +- **Best for Salesforce Service Cloud operations: Salesforce Agentforce** + +Suite-native products may deploy faster inside their own help desks, while an orchestration workspace is usually more flexible when support work crosses a help desk, CRM, order system, knowledge source, Slack, and internal APIs. Product positioning in this article is current as of October 2026. + +## What are the key facts about Sim, n8n, and Zapier? + +Sim, n8n, and Zapier differ materially in licensing, deployment, and billing, so buyers should not treat them as interchangeable automation products. + +- **Sim:** Sim's core is [Apache 2.0 licensed](https://github.com/simstudioai/sim/blob/main/LICENSE) and can be self-hosted, while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. On Sim Cloud, workspace BYOK works on every plan, organization-level keys require Pro for Teams, Max for Teams, or Enterprise, and hosted model keys carry about a 1.1x multiplier on provider cost according to the [Sim cost documentation](https://docs.sim.ai/platform/costs#bring-your-own-key-byok). Ollama, vLLM, LM Studio, and LiteLLM-backed local models work on any self-hosted deployment without requiring Enterprise. +- **n8n:** n8n offers [Cloud and self-hosted deployment](https://docs.n8n.io/choose-how-to-use-n8n), but its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) is source-available rather than OSI-approved open source. [n8n Cloud pricing](https://n8n.io/pricing/) is based primarily on workflow executions rather than individual steps. +- **Zapier:** Zapier is a proprietary hosted service. Its plans use tasks as a central usage unit, and a successful action generally counts as one task, according to [Zapier's task-usage documentation](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier). + +These pricing, plan, licensing, deployment, and billing-unit statements are current as of October 2026. + ## How does ticket triage fit into support automation? Triage is the first job most support teams automate: an agent classifies each incoming ticket by topic and urgency, assigns a priority, and routes it to the right queue before a human opens it. A billing complaint lands with the billing team and an outage report escalates to on-call. @@ -52,7 +121,7 @@ Triage has its own buying criteria, including labeled test sets, urgent-ticket m ## Can AI agents convert customer feedback into tickets? -Yes, AI agents convert raw customer feedback into structured helpdesk tickets, and they do it by extracting intent from unstructured text before writing a clean record a human can act on. An agent ingests feedback from post-support surveys, app store reviews, or a shared support channel, reads the sentiment and the underlying request, then creates a ticket in your helpdesk with a category, priority, and summary already filled in. +AI agents can convert raw customer feedback into structured helpdesk tickets by extracting intent from unstructured text before writing a clean record a human can act on. An agent ingests feedback from post-support surveys, app store reviews, or a shared support channel, reads the sentiment and the underlying request, then creates a ticket in your helpdesk with a category, priority, and summary already filled in. The quality of that auto-created ticket depends on two things: how well the agent understands your product and how deeply it connects to your helpdesk. Knowledge grounding decides whether the agent classifies a vague complaint correctly or files it under the wrong queue. An agent grounded in your docs and macros knows that "the export keeps timing out" belongs to the billing-export bug queue, not general feedback. Without that grounding, you get a ticket a human has to re-triage, which defeats the point. @@ -72,68 +141,171 @@ Whether you choose draft-and-approve or full autonomy separates most buyers. A d ## n8n for technical teams building custom support workflows -[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. +[n8n](https://n8n.io/pricing/) is the pick for technical teams that want node-based control over every branch of a support workflow. As of October 2026, its [integration directory](https://n8n.io/integrations) documents the available nodes and workflow templates. 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. +The [n8n integrations directory](https://n8n.io/integrations) and workflow templates shorten the path from a blank workflow to a working flow. You can adapt a community workflow for ticket enrichment or Slack escalation 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. This capability statement is current as of October 2026. -Hosting is not what separates n8n from Sim, since both offer a managed cloud product and a self-hosted path you run in your own infrastructure. The license is the real difference. n8n ships under the Sustainable Use License, a fair-code model that restricts certain commercial uses and hosting-as-a-service arrangements. Sim's core ships under Apache 2.0, a fully permissive license that lets you run, modify, and commercialize the core code without those commercial-use restrictions. If your legal team needs a clean permissive license, that distinction decides the choice before you write a single workflow. +Hosting is not what separates n8n from Sim, since n8n documents both [Cloud and self-hosted options](https://docs.n8n.io/choose-how-to-use-n8n). The license is the real difference: n8n's [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) is source-available and restricts certain commercial uses, while Sim's core uses Apache 2.0 and [its enterprise directory is separately licensed](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). This licensing statement is current as of October 2026. The main concession is the build curve. n8n provides native nodes for assembling a RAG pipeline, but you still configure the document loading, embeddings, vector store, and retrieval logic that grounds a triage agent in your documentation. Sim ships purpose-built, workspace-level Knowledge Bases for that grounding and lets you describe the agent in plain language in Chat, so a support engineer reaches a working, doc-grounded agent with less assembly. Pick n8n when you want maximum control and are willing to build the grounding pipeline. Pick Sim when you want that layer ready out of the box. ## Zapier for teams that want the largest app catalog -[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. +[Zapier](https://zapier.com/pricing) wins on catalog breadth. As of October 2026, its official [app directory lists more than 9,000 connections](https://zapier.com/apps), including support and CRM apps. When your goal is wiring one event to another action across a sprawling SaaS stack, that breadth makes Zapier a strong starting point. -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. +Zapier pairs that breadth with trigger-and-action workflows. For teams already standardized on Zapier across marketing and sales operations, that familiar model can reduce the learning curve. Zapier's limits show up the moment your triage logic gets complicated. Zapier's agent capabilities and knowledge grounding stay shallow compared to a purpose-built AI workspace, so an agent that needs to read a ticket, weigh it against your documented policies, and route it by nuanced intent is harder to express as a linear Zap. You can end up chaining filters and paths that grow brittle as edge cases pile up, because Zapier was built to move data between apps before it added agents. -Zapier Agents and Chatbots can attach FAQs, documents, tables, and webpages as knowledge sources. Those sources are configured per agent rather than managed as a reusable, workspace-level Knowledge Base, so grounding multiple support workflows can mean copying and maintaining context across agents. Pick Zapier when your priority is connecting the widest possible set of tools with minimal setup, and reach for an AI workspace when reusable grounding and nuanced routing matter more than the length of the connector list. +As of October 2026, [AI by Zapier accepts knowledge sources](https://zapier.com/apps/ai/integrations) alongside prompts and tools. Pick Zapier when your priority is connecting a wide set of cloud tools with minimal setup, and choose an AI workspace when reusable grounding and nuanced routing matter more than catalog breadth. ## Make for visual, multi-step support automations -Make earns its place when your support workflows branch in ways a linear tool can't express. Its scenario builder lets you draw conditional paths visually, so a ticket that meets one condition routes one way and a ticket that fails it takes another. You can nest routers, add filters between modules, and build error-handling branches without writing code. For a support team mapping out a triage flow with a dozen possible outcomes, that visual model is easier to reason about than a script or a flat rule list. +Make earns its place when your support workflows branch in ways a linear tool cannot express; as of October 2026, its [pricing page documents a visual workflow builder, routers, filters, and 3,000+ apps](https://www.make.com/en/pricing). Its scenario builder lets you draw conditional paths visually, so a ticket that meets one condition routes one way and a ticket that fails it takes another. You can nest routers, add filters between modules, and build error-handling branches without writing code. For a support team mapping out a triage flow with a dozen possible outcomes, that visual model is easier to reason about than a script or a flat rule list. -The scenario builder shines on the operations side of support automation. You can pull a new Zendesk ticket, check its priority and tags, split the flow across several routes, and post to different Slack channels or update different CRM records depending on what each branch decides. When your logic depends on many overlapping conditions, seeing the whole tree on one canvas beats debugging it in text. +The scenario builder shines on the operations side of support automation. Make lists native integrations for [Zendesk](https://www.make.com/en/integrations/zendesk), [Intercom](https://www.make.com/en/integrations/intercom), [Freshdesk](https://www.make.com/en/integrations/freshdesk), and [Salesforce](https://www.make.com/en/integrations/salesforce), each with its own supported triggers and actions as of October 2026. Teams should still test the exact fields and writes they require. -Make's weakness surfaces once the agent itself needs to reason across reusable workspace knowledge rather than follow rules you drew. Make's Knowledge feature can ground an AI Agent with uploaded context files backed by RAG, but that context remains attached to the agent rather than becoming a shared Knowledge Base that workflows across the workspace can reuse. The scenario still requires you to arrange the surrounding retrieval, routing, and helpdesk actions as modules. +Make is strongest when the team wants to arrange retrieval, routing, and help-desk actions as visual modules. Buyers should test whether its knowledge setup and agent controls match their reuse and governance requirements rather than infer those capabilities from the integration count. That difference defines who Make fits. If your support automation is mostly deterministic routing with occasional AI classification, Make handles it cleanly. If you want a workspace-level knowledge layer that multiple agents use to read a ticket, retrieve the right macro, and draft a grounded reply, Sim's Knowledge Bases and natural-language building in Chat target that case directly. ## Gumloop for ops teams automating support workflows with templates -[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. +[Gumloop](https://www.gumloop.com/pricing) earns its place for ops-led teams that prioritize configurable cloud workflows. As of October 2026, its documentation describes agents, workflows, connectors, MCP servers, and credit-based usage. -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. +The template approach shapes the whole product. Gumloop's UX supports configurable workflows and common tasks such as feedback intake, tagging, and handoffs. As of October 2026, Gumloop documents [credit-based usage](https://docs.gumloop.com/core-concepts/credits) and [50+ prebuilt MCP servers plus custom MCP connectivity](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers), so teams should confirm whether a required help-desk action is native or API-based. 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 workflow. -That same design choice sets the ceiling. Gumloop does not give you the self-hosting control that regulated or security-conscious teams need, and it does not expose the custom agent design you would build if your support logic outgrows the template it started from. When your routing rules stop fitting a pre-made pattern, you hit the edge of what the platform wants you to do. +Gumloop's fit depends on the exact connector and control requirements. Teams with infrastructure, governance, or bespoke routing requirements should test those needs directly before committing. -For teams that need to run everything inside their own infrastructure or wire up bespoke agent behavior, Sim offers Apache 2.0 core self-hosting and Knowledge Bases that ground agents in your own docs and macros. Gumloop wins on speed for standard ops workflows. Sim wins when the workflow has to be yours, hosted where you choose and built to logic no template anticipated. Match the tool to how far your support automation will eventually stretch. +For teams that need to run everything inside their own infrastructure or wire up bespoke agent behavior, Sim offers Apache 2.0 core self-hosting, with [enterprise features separately licensed](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), and Knowledge Bases that ground agents in your own docs and macros. Gumloop wins on speed for standard ops workflows. Sim wins when the workflow has to be yours, hosted where you choose and built to logic no template anticipated. Match the tool to how far your support automation will eventually stretch. ## Dify as a secondary option for LLM-native teams -Dify earns a spot on this list if you're building an LLM-first conversational app rather than automating a broad support ops stack. Its prompt and app management tooling is genuinely strong, and it gives you a clean workspace for versioning prompts, testing model outputs, and shipping a chat-style agent grounded in your own content. For a support team whose main goal is a single conversational assistant, that focus pays off. +Dify earns a spot on this list if you're building an LLM-first conversational app rather than automating a broad support ops stack. As of October 2026, Dify documents [knowledge and tool integrations](https://docs.dify.ai/en/self-host/use-dify/workspace/plugins), [REST APIs for deployed apps](https://docs.dify.ai/en/api-reference/guides/get-started), and [self-hosting with Docker](https://docs.dify.ai/en/self-host/deploy/overview). For a support team whose main goal is a single conversational assistant, that focus pays off. The tradeoff shows up the moment you need to reach into the tools your support team already runs. Dify's integration breadth trails the workflow-automation platforms above it, so wiring an agent into Zendesk, Intercom, or a CRM takes more custom work than it does on Sim or Zapier. If your support automation lives mostly inside chat and rarely touches other systems, Dify handles it well. If a ticket has to flow through triage, routing, and a helpdesk record, you'll feel the gaps, and a platform built around integrations and grounding fits better. ## How the top AI agent platforms for customer support compare -The six platforms below split along a clear line. Some optimize for broad integration reach and template speed, and others give you agent depth and self-hosting control. Read the table across these axes to match a platform to how your support team actually works, not to a generic feature count. +Sim leads this comparison for cross-system customization and explicit human-control patterns. n8n, Zapier, Make, Gumloop, Dify, and suite-native products each have a narrower best-fit scenario. Facts about changing product capabilities are current as of October 2026. + +| Platform | Best for | Operating model | Support-stack fit | Human escalation approach | +| --- | --- | --- | --- | --- | +| Sim | Custom multi-step support workflows | Natural-language Chat and visual workflow builder; 1,000+ integrations; API, hosted chat, and MCP deployment; Apache 2.0 core with [separately licensed enterprise features](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | Best when work crosses a help desk, CRM, knowledge source, and internal systems | Human in the Loop pauses for submitted fields; a downstream Condition must evaluate approval or rejection | +| n8n | Technical teams running node-based automations | [Cloud or self-hosted workflows](https://docs.n8n.io/choose-how-to-use-n8n); its current directory describes 1,000+ integrations | Native nodes exist for [Zendesk](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.zendesk/), [Intercom](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.intercom/), [Freshdesk](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.freshdesk/), and [Salesforce](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.salesforce/) | Assemble escalation as workflow logic and destination actions | +| Zapier | Straightforward SaaS handoffs | Hosted app automation with [9,000+ connections](https://zapier.com/apps) and task-based billing | Zapier documents native apps for [Zendesk](https://help.zapier.com/hc/en-us/articles/8495936547085-How-to-get-started-with-Zendesk-on-Zapier), [Intercom](https://help.zapier.com/hc/en-us/articles/8495977468301-How-to-get-started-with-Intercom-on-Zapier), [Freshdesk](https://help.zapier.com/hc/en-us/articles/40859563158285-How-to-get-started-with-Freshdesk-on-Zapier), and [Salesforce](https://help.zapier.com/hc/en-us/articles/8496020790925-How-to-get-started-with-Salesforce-on-Zapier) | Assemble escalation with branches and destination actions | +| Make | Visual, branching workflow logic | Cloud scenarios with [3,000+ apps and credit billing](https://www.make.com/en/pricing) | Native Zendesk, Intercom, Freshdesk, and Salesforce apps, with API calls available where a module lacks an action | Assemble escalation with routers and destination actions | +| Gumloop | Ops-led teams using templates | Configurable workflows with [credit-based usage](https://docs.gumloop.com/core-concepts/credits) | [50+ prebuilt MCP servers and custom MCP connectivity](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers); test help-desk actions individually | Configure review and destination steps in the workflow | +| Dify | LLM-first conversational apps | [Cloud](https://dify.ai/pricing) or [Docker self-hosting](https://docs.dify.ai/en/self-host/deploy/overview), with API and embedded web-app surfaces | Integrations include tools, data sources, MCP servers, and OpenAPI services; help-desk connectivity may be API-based | Configure escalation through workflow logic and connected tools | +| Zendesk AI | Zendesk-centered support | [AI agents inside Zendesk](https://www.zendesk.com/service/ai/ai-agents/) | Deepest fit for Zendesk records and channels | Zendesk documents [handoff from an AI agent to a live agent](https://support.zendesk.com/hc/en-us/articles/4408824482586-Managing-conversation-handoff-and-handback) | +| Intercom Fin | Intercom-centered support | [Fin inside Intercom or connected to an existing help desk](https://www.intercom.com/help/en/articles/8344190-pricing-faqs) | Deepest fit for Intercom conversations and knowledge | Uses Intercom inbox and teammate workflows | +| Freshworks Freddy AI | Freshdesk-centered support | Evaluate inside the Freshdesk environment | Test suite fit directly; external agents can use the [Freshdesk REST API](https://developers.freshdesk.com/api/) | Test the required agent and group handoff | +| Salesforce Agentforce | Salesforce-centered service operations | Evaluate inside the Salesforce environment | Test required Service Cloud records and approved actions | Test permissions, queue assignment, and service handoff | + +Buyers should not infer that every platform has every native connector. + +## Which customer support systems should an AI agent integrate with? + +A customer support AI agent should integrate with the system that owns the case, the system that holds customer context, the approved knowledge source, and the destination for human escalation. + +| Support system | Verified connectivity as of October 2026 | Records an acceptance test should cover | Minimum safe write test | +| --- | --- | --- | --- | +| Zendesk | Sim, n8n, Zapier, and Make have native integrations; other platforms can use the [Zendesk Ticketing API](https://developer.zendesk.com/api-reference/ticketing/introduction/) | Tickets, users, comments, tags, status, and relevant help-center content | Add an internal note or approved reply and update only an allowed status or tag | +| Intercom | Sim, n8n, Zapier, and Make have native integrations; other platforms can use the [Intercom Conversations API](https://developers.intercom.com/docs/references/rest-api/api.intercom.io/conversations) | Conversations, contacts, tags, teammates, and approved knowledge | Draft or send an approved response and assign the conversation correctly | +| Freshdesk | n8n, Zapier, and Make have native integrations; Sim and other platforms can use the [Freshdesk REST API](https://developers.freshdesk.com/api/) | Tickets, contacts, notes, groups, status, and approved solution content | Add a private note or approved reply and update an allowed field | +| Salesforce Service Cloud | Sim, n8n, Zapier, and Make have native Salesforce integrations; test Service Cloud object coverage and use API connectivity for unsupported actions | Cases, contacts, accounts, knowledge, queues, and approved actions | Create an approved case comment or update a permitted case field | + +A product-directory badge is not enough evidence for production readiness. The acceptance test should confirm authentication, required scopes, read and write behavior, pagination, rate-limit handling, attachments, custom fields, idempotency, and the identity under which every action is recorded. + +## What should an AI customer support agent automate? + +A customer support AI agent should automate bounded, auditable work first and reserve ambiguous, sensitive, or irreversible decisions for people. + +1. Retrieve the ticket, customer record, entitlement, recent orders, and approved knowledge. +2. Classify intent, urgency, language, sentiment, and required skill. +3. Produce a grounded summary and suggested next action. +4. Draft a response using only approved sources. +5. Check the draft against policy and risk rules. +6. Request human review when the case crosses an escalation threshold. +7. Send the approved response or create the approved internal action. +8. Update the case and record the decision, evidence, and outcome. + +Refunds, credits, account closures, identity changes, legal threats, security incidents, health or safety claims, and high-value contractual decisions normally require deterministic limits or human authorization. + +## How is customer support automation different from ticket triage? + +Customer support automation executes work across the case lifecycle, while ticket triage primarily classifies, prioritizes, and routes incoming requests. + +Triage determines which queue owns a ticket, how urgent it is, and which skill is required. Broader automation may retrieve order data, generate a grounded answer, request approval, update a CRM record, trigger a replacement, notify an internal team, and close the loop with the customer. Teams that only need classification and routing should use the [Best AI Agents for Customer Support Ticket Triage and Routing](https://www.sim.ai/library/best-ai-agents-support-ticket-triage). + +## How should AI agents escalate customer support cases to humans? + +Sim should escalate a support case by pausing at a defined risk boundary, collecting a human decision, and routing the result through an explicit downstream Condition. + +The Human in the Loop block pauses a run and resumes it with submitted form fields. Approval or rejection can be collected as a field, but the field does not enforce a branch by itself. The workflow must evaluate it with a downstream Condition. A safe escalation package includes the customer's request, relevant history, proposed answer or action, approved sources, escalation reason, exact planned changes, reviewer choices, identity, and timestamp. [What Is Human-in-the-Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) explains the approval pattern in more detail. + +## What safety controls should customer support AI agents have? + +Sim customer support workflows should combine least-privilege credentials, grounded context, deterministic conditions, guardrail checks, human approval, and traceable outcomes. + +Sim's Guardrails block reports whether content passed or failed; it does not stop a run on its own. A downstream Condition must route a failed result away from sending, updating, refunding, or performing another sensitive action. A production design should also use separate credentials for drafting and action execution, allowlisted tools and fields, approved knowledge, confidence thresholds, deterministic financial limits, idempotency protection, redaction, traces, a kill switch, and a manual fallback queue. + +A Wait block resumes after a specified duration. It does not wait for an external event, so workflows that need a human decision should use Human in the Loop instead of treating a timed wait as approval. + +## How should teams prevent hallucinated customer support answers? + +Sim should prevent unsupported customer answers by grounding generation in approved sources, requiring evidence, checking the output, and escalating when context is insufficient. + +The agent should receive only the relevant policy, account, product, and case context. Useful controls include citation requirements, source-freshness checks, retrieval filters, deterministic policy checks, structured outputs, prohibited-claim detection, and evaluation sets built from real support cases. + +## How should buyers evaluate an AI agent for customer support automation? + +A buyer should evaluate a customer support AI agent against real cases, real permissions, and real failure modes rather than selecting it from a feature checklist alone. + +| Criterion | What a passing test demonstrates | +| --- | --- | +| Integration depth | Required standard and custom fields can be read and written without excessive workarounds | +| Grounding | Answers rely on approved, current knowledge and customer context | +| Action control | Sensitive actions have deterministic limits and least-privilege credentials | +| Human escalation | Reviewers receive enough context and the workflow routes their decision explicitly | +| Reliability | Retries, duplicate events, timeouts, and partial failures do not corrupt records | +| Observability | Operators can reconstruct inputs, model output, tool calls, decisions, and final action | +| Evaluation | The team can test resolution quality, policy compliance, escalation accuracy, and regressions | +| Deployment | The product fits the organization's cloud, self-hosting, networking, and data requirements | +| Cost model | Billing remains predictable at expected ticket, execution, task, model, and review volumes | +| Maintainability | Support operations can update prompts, policies, routing rules, and integrations safely | + +## Which metrics should customer support automation teams track? + +A customer support automation team should track resolution quality and risk alongside containment, speed, and cost. + +Track correct-resolution, unsupported-claim, and policy-compliance rates; escalation precision and recall; human overrides; reopened cases and repeat contacts; first-response and resolution time; customer satisfaction by path; cost per resolved case; integration failures; duplicate actions; and trace completeness. Containment alone can reward an agent for keeping cases away from people even when its answers are incomplete or wrong. + +## When should a team choose Sim instead of a dedicated support chatbot? + +Sim is the better choice when customer support work must cross multiple systems or requires custom logic, models, approvals, and actions beyond a dedicated chatbot's standard conversation flow. + +A dedicated chatbot can be faster when the main requirement is answering common questions inside one support suite. Sim is more compelling when the process must retrieve operational data, apply company-specific rules, coordinate internal tools, pause for approval, and continue through downstream work. + +## How should a team roll out customer support automation safely? + +Sim support automation should begin in read-only or draft mode, advance through human-approved execution, and earn broader autonomy only after measured performance meets a defined threshold. + +1. Run historical cases as an offline evaluation set. +2. Deploy summarization and classification without customer-facing writes. +3. Generate reply drafts for human review. +4. Permit low-risk internal updates such as tags or notes. +5. Add human-approved external replies and bounded actions. +6. Automate only high-confidence, low-risk cases with a clear fallback. +7. Review traces, overrides, complaints, and regressions continuously. -| Platform | Builder model | Agent depth | Knowledge grounding | Integrations | Deployment surfaces | License / hosting | Pricing model | Best-fit ICP | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Sim | Natural-language (Chat) + visual | Deep, multi-step agents | Native Knowledge Bases | 1,000+ | API, hosted chat interface, MCP tool | Apache 2.0 core (enterprise features separately licensed), cloud or self-host | Usage-based tiers | Teams wanting open-source AI workspace | -| n8n | Node-based visual | Moderate, DIY assembly | Native RAG nodes, configurable pipeline | 1,900+ listed | API, webhook | Sustainable Use License, cloud or self-host | Execution-based | Technical teams needing node control | -| Zapier | Linear step builder | Moderate | Per-agent knowledge sources | 9,000+ | Webhook, embed | Proprietary, cloud only | Task-based | Ops teams standardized on Zapier | -| Make | Visual scenario builder | Moderate | Agent Knowledge with RAG | 3,000+ | Webhook, API | Proprietary, cloud only | Operations-based | Teams needing branching visual logic | -| Gumloop | Template-driven visual | Moderate | Basic | 100+ | Webhook, API | Proprietary, cloud only | Credit-based | Ops-led teams wanting templates | -| Dify | LLM-app builder | Deep for chat | Native RAG | Narrower | API, embed widget | Open-source, cloud or self-host | Usage-based | LLM-first app teams | +Each stage needs an owner, entry criteria, rollback conditions, and a maximum permitted impact. Grant autonomy per action type rather than to the entire agent at once. ## Which AI agent platform fits your team? -Your best pick depends on what your team controls and where the workflow needs to live. +Sim is the best pick when a team needs to control custom support work across multiple systems; the alternatives fit narrower operating models. -Technical teams that need to self-host and own the code should compare Sim and n8n directly. Both offer cloud and self-hosted paths, so the license decides between them. Sim's core ships under Apache 2.0 with no commercial-use restrictions and gives you native Knowledge Bases plus natural-language building in Chat, which removes much of the RAG assembly n8n's node-based engine requires for grounded support agents. Choose n8n when you want granular node-level control and already have engineers comfortable configuring their own retrieval logic. +Technical teams that need to self-host and own the code should compare Sim and n8n directly. Both offer cloud and self-hosted paths, so the license decides between them. Sim's core ships under Apache 2.0 with no commercial-use restrictions, while [enterprise features are separately licensed](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), and gives you native Knowledge Bases plus natural-language building in Chat, which removes much of the RAG assembly n8n's node-based engine requires for grounded support agents. Choose n8n when you want granular node-level control and already have engineers comfortable configuring their own retrieval logic. Ops-led teams optimizing existing workflows should start with Zapier or Gumloop. Zapier wins when your stack already spans dozens of tools and you want the broadest catalog to connect them. Gumloop wins when you want support-specific templates that get a triage or feedback-to-ticket flow running quickly. Both prioritize setup speed, so compare them with Sim when your triage logic needs reusable workspace knowledge and deeper agent control. @@ -141,4 +313,4 @@ Enterprise teams that need governance and scale should weigh Sim's self-hosting Start where the friction is lowest. Open a hosted account at [sim.ai](https://www.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: [the best AI agents for support ticket triage](/library/best-ai-agents-support-ticket-triage) goes deeper on classification, routing, and evaluation, [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. +Related reading: [the best AI agents for support ticket triage](https://www.sim.ai/library/best-ai-agents-support-ticket-triage) goes deeper on classification, routing, and evaluation, [AI agent vs chatbot](https://www.sim.ai/library/ai-agent-vs-chatbot) explains why a support agent is different from a support chatbot, [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) compares the platforms behind these builds, and [how to build AI agents](https://www.sim.ai/library/how-to-create-an-ai-agent) is the general walkthrough. From 2b007e1615d31a6ded6ba042b52ae77393c13089 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 17:13:11 -0700 Subject: [PATCH 21/68] docs(library): update ai-agent-observability (#8645) Co-authored-by: Sim Pi Agent --- .../library/ai-agent-observability/index.mdx | 153 ++++++++++++++++-- 1 file changed, 144 insertions(+), 9 deletions(-) diff --git a/apps/sim/content/library/ai-agent-observability/index.mdx b/apps/sim/content/library/ai-agent-observability/index.mdx index cccd2b4e781..9e29af61495 100644 --- a/apps/sim/content/library/ai-agent-observability/index.mdx +++ b/apps/sim/content/library/ai-agent-observability/index.mdx @@ -1,12 +1,12 @@ --- slug: ai-agent-observability title: 'What Is AI Agent Observability? Traces, Metrics, and Evals Explained' -description: What AI agent observability is, why traditional monitoring misses agent failures, and what to instrument at each stage, from traces and logs to metrics and evaluations. +description: 'What AI agent observability is, why traditional monitoring misses agent failures, and what to instrument at each stage, from traces and logs to metrics and evaluations.' date: 2026-07-19 -updated: 2026-09-30 +updated: 2026-10-06 authors: - andrew -readingTime: 9 +readingTime: 13 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. @@ -26,12 +26,56 @@ faq: 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." + - q: "What is AI agent observability?" + a: "AI agent observability is the practice of collecting traces, tool calls, cost, latency, errors, and evaluation results so teams can explain and improve an agent’s behavior." + - q: "Why is AI agent observability important?" + a: "AI agent observability is important because an agent can complete a run without a technical error while still choosing the wrong tool, using weak evidence, overspending, or producing an unacceptable result." + - q: "What should you monitor in an AI agent?" + a: "AI agent teams should monitor traces, tool calls, cost, latency, errors, evaluation results, model versions, prompt versions, workflow versions, retries, and final task outcomes." + - q: "What is an AI agent trace?" + a: "An AI agent trace is a connected record of the model, retrieval, routing, tool, approval, and other steps performed during one run." + - q: "What is the difference between AI agent monitoring and observability?" + a: "AI agent monitoring reports predefined health signals, while AI agent observability preserves enough connected evidence to investigate why a run behaved as it did." + - q: "How is AI agent observability different from LLM observability?" + a: "AI agent observability includes LLM inputs, outputs, latency, and usage but also covers control flow, retrieval, tools, retries, approvals, external effects, and task completion." + - q: "How is AI agent observability different from application performance monitoring?" + a: "AI agent observability adds model behavior, tool selection, probabilistic decisions, output quality, safety, and task success to the infrastructure signals collected by application performance monitoring." + - q: "What metrics should an AI agent dashboard include?" + a: "An AI agent dashboard should include run volume, success rate, error rate, task completion, evaluation pass rate, cost per run, token or model usage, end-to-end latency, step latency, retry rate, and tool failure rate." + - q: "How do you evaluate an AI agent in production?" + a: "AI agent production evaluation combines automated checks, sampled human review, user feedback, policy tests, business outcomes, and trace evidence tied to the exact workflow and model versions used." + - q: "How do you debug an AI agent that gives the wrong answer?" + a: "AI agent debugging starts with the affected run, finds the first trace step that diverged from expected behavior, identifies the responsible data or configuration version, and adds the failure to a regression evaluation set." + - q: "How do you monitor AI agent tool calls?" + a: "AI agent tool-call monitoring records the tool name, sanitized arguments, authorization context, result, duration, retries, and external effect for each attempted action." + - q: "How do you monitor AI agent cost?" + a: "AI agent cost monitoring attributes model and service usage to each run and step, then compares that cost with quality and task-completion results." + - q: "How do you reduce AI agent latency?" + a: "AI agent latency is reduced by using traces to locate slow model, retrieval, tool, retry, queue, or approval steps and then optimizing the specific bottleneck." + - q: "What is a silent failure in an AI agent?" + a: "An AI agent silent failure occurs when a run appears technically successful but produces an incorrect, unsupported, unsafe, incomplete, or otherwise unacceptable result." + - q: "Does OpenTelemetry support AI agent observability?" + a: "OpenTelemetry provides vendor-neutral traces, metrics, logs, and context propagation that can form the telemetry foundation for AI agent observability." + - q: "Should AI agent traces store prompts and tool inputs?" + a: "AI agent traces should store only the prompt and tool-input data needed for debugging under explicit redaction, access, retention, and privacy controls." + - q: "How long should AI agent traces be retained?" + a: "AI agent trace retention should follow the organization’s debugging, security, legal, privacy, and audit requirements rather than an unlimited default." + - q: "How does Sim trace AI agent runs?" + a: "Sim logs and traces workflow runs so teams can inspect executed steps, follow the workflow path, investigate failures, and connect outcomes to the workflow version that produced them." + - q: "Can Sim observability work with external monitoring systems?" + a: "Sim run data can be correlated with model-provider usage, external service telemetry, security records, and evaluation results through shared run identifiers and an organization’s observability architecture." + - q: "Can n8n workflows be observed like AI agents?" + a: "n8n workflows can be instrumented with run records, tool and node activity, latency, errors, usage data, and task-specific evaluations when they perform agentic work." + - q: "What are the best AI agent observability tools?" + a: "The best AI agent observability tools connect complete traces with tool activity, cost, latency, errors, evaluations, version metadata, privacy controls, and export options." --- 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. +AI agent observability is the practice of collecting and connecting traces, tool calls, costs, latency, errors, and evaluation results so teams can explain an agent’s behavior and improve it safely. + 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 @@ -49,7 +93,7 @@ AI agent observability is the practice of capturing and analyzing an agent's int 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. +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. A useful observability record connects the request or trigger, the workflow path, each model, retrieval, and tool operation, and the final result’s evaluation. Monitoring reports that something happened; observability preserves enough connected evidence to investigate why. | Pillar | What It Captures | Example Data Point | Why It Matters | | --- | --- | --- | --- | @@ -66,11 +110,21 @@ Agents break that assumption. The same prompt can trigger different tool sequenc 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. +| Application Monitoring Question | AI Agent Observability Question | +| --- | --- | +| Did the request return successfully? | Did the run complete the intended task correctly? | +| Which service was slow? | Which model, retrieval, tool, retry, or approval step was slow? | +| What exception occurred? | Did the agent fail technically, choose the wrong action, or produce a weak answer? | +| How much compute was used? | What did this run cost, and was the result worth that cost? | +| Is the service available? | Is the agent reliable, safe, and effective for this task? | + +Traditional metrics remain necessary, but they cannot establish whether a technically successful response was grounded, appropriate, or useful. + ## 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. +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. As of October 2026, 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. @@ -102,6 +156,19 @@ You need full execution context, including conversation history, retrieval resul 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. +Monitor traces, tool calls, cost, latency, errors, and evaluations together because no single signal explains both reliability and output quality. + +| Signal | What It Answers | Minimum Fields to Capture | Useful Alert or Review Trigger | +| --- | --- | --- | --- | +| Traces | What path did the agent take? | Run ID, parent and child spans, step name, start time, end time, status | Unexpected branch, repeated step, missing span, or unusually deep run | +| Tool calls | Which external action did the agent attempt? | Tool name, sanitized arguments, result status, retry count, response summary | Unauthorized tool, repeated failure, malformed arguments, or destructive action | +| Cost | How much did the run consume? | Model, token or usage count, provider charge where available, run total | Cost per run or task exceeds a defined budget | +| Latency | Where did the run spend time? | Total duration and duration by model, tool, retrieval, and approval step | End-to-end or step latency exceeds a service objective | +| Errors | What failed technically? | Error type, affected step, retry state, dependency, sanitized message | Error-rate spike, exhausted retries, or recurring dependency failure | +| Evals | Was the result useful, correct, and safe? | Evaluator name, criterion, score, threshold, evidence, evaluator version | Quality regression, safety failure, or score below threshold | + +Also record the workflow, model, prompt or instruction, tool, and evaluator versions; environment; timestamp; and a user or tenant identifier where appropriate. These fields make regressions reproducible instead of anecdotal. + Then, add the agent-specific signals traditional tools miss: - **Tool selection accuracy:** Did the agent choose the right tool for the step? @@ -111,7 +178,51 @@ Then, add the agent-specific signals traditional tools miss: 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. +Retrieval-heavy agents add their own failure surface, which we cover in [the best AI agents for data extraction and RAG](https://www.sim.ai/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](https://www.sim.ai/library/what-is-an-mcp-server) explains the tool boundary you'll be tracing across. + +## Concrete AI Agent Observability Examples + +The value becomes clearer when traces, metrics, tool records, and evaluations diagnose the same production run: + +- A customer-support agent completes a refund, but its trace shows that it skipped the eligibility lookup and called the refund tool directly. A policy evaluation catches the missing check even though the action succeeded. +- A research agent becomes slower and more expensive after a workflow change. Step-level latency and cost attribution reveal repeated retrieval calls that add no useful evidence. +- A procurement agent cannot create a purchase request. Tool-call logs expose an invalid cost-center field, while version metadata identifies the prompt change that introduced it. +- A sales agent returns a plausible account summary, but a groundedness evaluation finds claims unsupported by the retrieved CRM records. +- A multi-step operations agent appears stuck. Its trace reveals a routing loop that sends the same failed tool result back to the model until the retry limit is reached. + +Success status alone is therefore not a sufficient reliability measure. + +## Traces, Tool Calls, Cost, Latency, Errors, and Evals + +A trace reconstructs a run as connected operations. It begins with a request or workflow trigger, while child spans represent model calls, retrieval, routing, tool execution, retries, and human approvals. The [OpenTelemetry trace model](https://opentelemetry.io/docs/concepts/signals/traces/) provides a vendor-neutral parent-child structure, and shared trace context connects downstream retrieval services or business APIs to the original request. + +A tool-call record should include the selected tool, sanitized arguments, authorization context, result, duration, retry count, and downstream effect. Distinguish a proposed action from a completed one and record whether approval or a policy check preceded execution. Redact secrets, authentication tokens, personal data, and unnecessary payloads before storage. + +Attribute model, retrieval, and tool usage to each run and step, then aggregate it by workflow, customer, task, model, or environment. Evaluate cost alongside quality: a cheaper model can cost more overall when failed runs and human rework increase. Measure latency end to end and by model call, retrieval, tool, retry, queue, and approval wait. Use p50, p95, and p99 to expose outliers hidden by an average, and separate active processing from intentional waiting. + +Error detection should cover model timeouts and malformed responses; invalid tool arguments and exhausted retries; missing or irrelevant retrieval context; loops and unreachable branches; and silent failures such as unsupported claims or incorrect actions. Evaluations then judge correctness, relevance, groundedness, safety, and task completion. Offline evals use fixed datasets around changes, while online evals assess sampled production runs, feedback, or policy checks. Store each criterion, evaluator, method, threshold, evidence, and evaluator version with the trace. + +## How to Debug an AI Agent With Traces + +Start from the failed or low-quality run and compare its path with a known-good run: + +1. Find the run by its ID, user report, error, time range, or evaluation failure. +2. Confirm the workflow, prompt, model, tool, and evaluator versions. +3. Locate the first span where the failed run diverged from expected behavior. +4. Inspect sanitized inputs, outputs, tool arguments, retries, and routing decisions at that step. +5. Classify the root cause as data, instructions, model behavior, control flow, permissions, or an external dependency. +6. Reproduce the behavior against a controlled test case. +7. Add the failure to a regression evaluation set before deploying the fix. + +The first visible error may be downstream of the cause. An invalid tool call can begin with an earlier extraction or routing decision. + +## How Sim Logs and Traces AI Agent Runs + +As of October 2026, Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Every workflow run is logged, and Sim’s Logs page records the run ID, workflow ID, trigger, timestamps, total duration, cost and token breakdowns, run data with trace spans, final output, and associated files. The detail view exposes block-level inputs and outputs, while a workflow snapshot preserves the workflow state used for that run. These capabilities are documented in [Sim’s logging reference](https://docs.sim.ai/logs-debugging/logging). + +Use those records to determine which blocks ran, what sanitized data moved through them, where time or errors accumulated, and which saved workflow state produced the outcome. Sim’s execution model supports nested workflow traces, and [Inside the Sim Executor: DAG Based Execution with Native Parallelism](https://www.sim.ai/blog/executor) explains how its graph execution works. Parent-child span relationships help distinguish concurrent branches from duplicated or looping work. + +Workflow logs are product-level records rather than infrastructure logs. For production observability, correlate their execution IDs with model-provider usage, external service telemetry, security records, and task-specific evaluations. Sim records exact substituted secret values as masked in log-facing views, but teams should still minimize sensitive telemetry and apply appropriate access, redaction, retention, and deletion controls. ## How to Get Started With AI Agent Observability @@ -122,14 +233,38 @@ Here are some practical first steps you can take immediately: - **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. +A practical implementation checklist is: + +1. Assign a unique run ID and propagate trace context through every model, retrieval, tool, and approval operation. +2. Instrument each meaningful operation as a span with start time, end time, status, and parent relationship. +3. Record sanitized tool arguments, outcomes, retries, and authorization or approval state. +4. Attribute model usage and external-service costs to the corresponding run and step. +5. Store workflow, model, prompt, tool, and evaluator versions with each run. +6. Create offline regression evaluations for known tasks and production failures. +7. Add online alerts for error, latency, cost, safety, and quality thresholds. +8. Define access, redaction, retention, and deletion policies for telemetry data. +9. Review failed runs and a sample of successful runs to find silent quality regressions. + +The [NIST AI Risk Management Framework](https://www.nist.gov/itl/ai-risk-management-framework) offers a broader voluntary approach to governing and measuring AI risk, while OpenTelemetry supplies technical conventions for traces and related signals. + Plan for common challenges too: trace volume at scale, alert fatigue, fragmented visibility across systems, and privacy or PII handling in telemetry. -If you decide you need a dedicated platform, our comparison of the [best AI observability tools for production agents](/library/6-best-ai-observability-tools-for-production-agents-in-2026) weighs Braintrust, Galileo, Langfuse, Arize AX, Datadog, and PostHog on tracing, evaluations, and CI/CD checks. +When selecting a tool, look for end-to-end tracing across models, retrieval systems, tools, and services; searchable run and version history; run- and step-level latency and cost; offline and online evals; privacy controls; export options; and comparisons between failed and known-good runs. + +As of October 2026, n8n remains an incumbent workflow-automation product relevant to teams instrumenting agentic workflows. Its first-party documentation describes an [Executions list for reviewing and rerunning past workflow runs](https://docs.n8n.io/build/understand-workflows/understand-executions/view-executions-for-a-single-workflow) and [OpenTelemetry traces for workflow and node executions](https://docs.n8n.io/deploy/host-n8n/keep-n8n-running/trace-executions-with-opentelemetry). Those workflow records can be combined with usage data and task-specific evaluations when n8n handles agentic work. Sim approaches the problem from an AI-native workspace with workflow run logs and trace spans. + +If you decide you need a dedicated platform, our comparison of the [6 Best AI Observability Tools for Production Agents in 2026](https://www.sim.ai/library/6-best-ai-observability-tools-for-production-agents-in-2026) weighs Braintrust, Galileo, Langfuse, Arize AX, Datadog, and PostHog on tracing, evaluations, and CI/CD checks. + +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 page works this way, providing workflow logs, block-level trace data, timing, and cost breakdowns. If you are still assembling that workflow, [how to build AI agents with Sim](https://www.sim.ai/library/how-to-create-an-ai-agent) walks through the first one. + +## Minimum Viable Observability Setup + +At minimum, record a correlated trace, every tool call, step and total latency, model usage, errors, version metadata, and at least one task-specific evaluation. This baseline lets a team reconstruct a run and determine whether a change improved or degraded behavior. -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. +More mature implementations can add detailed cost attribution, automated anomaly detection, sampled human reviews, security analytics, and business-outcome measurements. Start with evidence that answers what happened and whether it was acceptable; add complexity only where incidents and operating requirements justify it. ## 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://www.sim.ai), so execution logs, trace spans, and per-model cost tracking come standard from your very first run. +[Create your next agent in the open-source AI workspace](https://www.sim.ai), where workflow execution logs, trace spans, and model-usage breakdowns make each run inspectable from the start. From 73bb265b6df847c49dc2c922bb54e11c0e9619e0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 17:14:28 -0700 Subject: [PATCH 22/68] docs(library): update aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean (#8647) Co-authored-by: Sim Pi Agent --- .../index.mdx | 245 +++++++++++++++++- 1 file changed, 243 insertions(+), 2 deletions(-) diff --git a/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx b/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx index a50e1453bc0..f00dd73119e 100644 --- a/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx +++ b/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx @@ -3,10 +3,10 @@ slug: aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually- title: 'AEO vs GEO: What Answer Engine and Generative Engine Optimization Actually Mean' description: 'Understand AEO vs GEO, how answer engine and generative engine optimization differ from traditional SEO, and the content practices that make answers easier to cite.' date: 2026-08-03 -updated: 2026-08-03 +updated: 2026-10-06 authors: - andrew -readingTime: 6 +readingTime: 17 tags: [SEO, Generative AI, Content Strategy, Sim] ogImage: /library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg draft: false @@ -19,10 +19,56 @@ faq: a: "Content that answers a specific question early, defines its terms, gives concrete evidence, and clearly states tradeoffs is easier for both readers and answer systems to use." - q: "Do comparison tables help with AEO and GEO?" a: "A comparison table can make distinctions between options explicit. It works best when its rows use clear criteria and its surrounding text explains the practical tradeoffs." + - q: "What does AEO stand for?" + a: "AEO stands for answer engine optimization, the practice of making content easy for answer systems to extract and present as a direct response." + - q: "What does GEO stand for in marketing?" + a: "GEO stands for generative engine optimization, the practice of making content easier for generative AI systems to retrieve, understand, cite, and synthesize." + - q: "Are AEO and GEO the same thing?" + a: "AEO and GEO are not exactly the same, although both reward clear answers, accessible content, accurate claims, and credible evidence." + - q: "Is AEO replacing SEO?" + a: "AEO is not replacing SEO because direct-answer visibility still depends on many of the same technical and content foundations used in search optimization." + - q: "How do you optimize for answer engines?" + a: "Answer engine optimization starts with the buyer’s exact question, a direct one-sentence answer, a clear entity definition, and evidence that supports the answer." + - q: "How do you optimize for generative engines?" + a: "Generative engine optimization combines direct answers with crawlable text, unambiguous entities, primary-source evidence, passage-level context, and repeated citation measurement." + - q: "How do you get cited by ChatGPT?" + a: "ChatGPT citations cannot be guaranteed, but accessible pages with clear answers, original evidence, explicit entities, and reliable primary sources are more suitable for retrieval and citation." + - q: "How do you get cited in Google AI Overviews?" + a: "Google AI Overview citations cannot be guaranteed, but Google recommends the same foundational practices used for search visibility, including indexable pages, useful content, and compliance with Search requirements." + - q: "How do you measure GEO?" + a: "GEO should be measured with citation rate, mention rate, attributed share of voice, source accuracy, AI referral traffic, and downstream conversions across a stable prompt set." + - q: "What is a good GEO citation rate?" + a: "A good GEO citation rate is a rate that improves against a documented baseline for commercially relevant prompts, because no universal benchmark applies across categories, systems, and prompt sets." + - q: "What is the difference between a mention and a citation?" + a: "A mention names an entity, while a citation identifies or links to a source used in or associated with the answer." + - q: "Does a brand mention count as a GEO citation?" + a: "A brand mention does not count as a GEO citation unless the response also attributes the information to the tracked source under the measurement method being used." + - q: "Does schema markup guarantee an AI citation?" + a: "Schema markup does not guarantee an AI citation because structured data can clarify content but cannot force a generative system to retrieve or select a source." + - q: "Is llms.txt required for GEO?" + a: "The llms.txt proposal is not required for GEO and should not replace crawlability, robots controls, sitemaps, internal links, or accurate page content." + - q: "Do AI crawler visits mean my content was used?" + a: "AI crawler visits mean a named user agent requested a resource, but those visits do not prove that the content was indexed, used for training, retrieved, displayed, or cited." + - q: "Can Google Search Console track AI Overview traffic?" + a: "Google Search Console includes traffic from Google’s AI search features within overall Web search reporting, but it does not provide a complete cross-platform measure of every AI citation or mention." + - q: "Can analytics track all AI traffic?" + a: "Web analytics cannot track all AI influence because many mentions and citations produce no click, and some visits may lack an identifiable referrer." + - q: "How often should GEO prompts be tested?" + a: "GEO prompts should be tested repeatedly on a consistent schedule because generated answers can vary by system, model, location, account state, retrieval index, and time." + - q: "How long does GEO take to work?" + a: "GEO has no fixed time to results because crawling, indexing, retrieval, and citation updates occur on different schedules across generative systems." + - q: "What content is most likely to be cited by AI?" + a: "AI systems are better able to cite content that gives a direct answer, identifies entities clearly, supplies verifiable evidence, preserves necessary context, and is technically accessible." + - q: "Should AEO and GEO be separate strategies?" + a: "AEO and GEO should usually be managed as one integrated strategy in which direct answers support extraction and evidence-rich passages support retrieval, synthesis, and citation." + - q: "What is the best way to start with AEO and GEO?" + a: "AEO and GEO should start with a small set of high-value buyer questions, baseline prompt tests, direct answer blocks, primary-source evidence, and a recurring measurement process." --- ## TL;DR +Answer engine optimization (AEO) makes content easier for search and answer systems to extract as a direct answer, while generative engine optimization (GEO) makes content easier for generative AI systems to retrieve, understand, cite, and synthesize into a response. + - **AEO (Answer Engine Optimization)** means structuring content so a machine can lift it whole into a direct answer, from [featured snippets](https://developers.google.com/search/docs/appearance/featured-snippets) to AI chat replies. - **GEO (Generative Engine Optimization)** means optimizing content so generative systems select and cite it when they synthesize an answer. - The terms now overlap heavily, and the label matters less than the mechanics both require. @@ -53,8 +99,22 @@ The single-answer framing is also less clean than it once was. [Google's descrip Arguing over which label is correct matters less than optimizing for machine extraction. Whether you call it AEO or GEO, answer the question early, write sections that stand alone when quoted, and support concrete specifics with sources a reader can check. +| Question | AEO | GEO | +| --- | --- | --- | +| What does it stand for? | Answer engine optimization | Generative engine optimization | +| What is the primary goal? | Help an answer system extract a correct, direct response | Help a generative system retrieve, trust, cite, and accurately synthesize the source | +| Which surfaces does it target? | Featured snippets, voice answers, knowledge results, search summaries, and direct-answer interfaces | AI search results, conversational assistants, research agents, and retrieval-augmented generative systems | +| What is the main content unit? | A concise answer block tied to a specific question | A self-contained, evidence-rich passage that remains useful when quoted or summarized | +| What matters most? | Directness, clarity, structure, relevance, and factual accuracy | Retrievability, entity clarity, evidence, source authority, corroboration, and citation suitability | +| What does success look like? | The content supplies or improves a direct answer | The domain or page is mentioned, cited, or accurately represented in generated answers | +| How is it measured? | Answer ownership, snippet visibility, impressions, clicks, and conversions | Citation rate, mention rate, attributed share of voice, source inclusion, referral traffic, and conversions | +| Does structured data help? | Structured data can clarify eligible content and entities but does not guarantee an answer placement | Structured data can reduce ambiguity but does not guarantee retrieval or citation | +| Does either replace SEO? | No | No | + ## Traditional SEO vs AEO vs GEO +SEO, AEO, and GEO emphasize different visibility outcomes while sharing the same technical and editorial foundation. + The three approaches emphasize different output surfaces, even though their content mechanics overlap in practice. | Dimension | Traditional SEO | AEO | GEO | @@ -65,8 +125,18 @@ The three approaches emphasize different output surfaces, even though their cont The row that matters most is tactics, and the overlap there is the point. Both AEO and GEO benefit from answering the question early and structuring content so a machine can lift it cleanly. Traditional SEO remains important for discovery, but it does not replace the need for passages that are understandable when quoted on their own. +| Discipline | Primary objective | Typical unit of visibility | Representative measurements | +| --- | --- | --- | --- | +| SEO | Earn visibility and visits from search results | Ranked page or search result | Rankings, impressions, clicks, click-through rate, organic sessions, and conversions | +| AEO | Supply a direct and correct answer | Extracted answer or answer block | Featured-answer ownership, answer inclusion, impressions, clicks, and conversions | +| GEO | Become a retrieved, cited, or accurately synthesized source | Citation, mention, or attributed passage | Citation rate, mention rate, share of voice, source inclusion, referral traffic, and conversions | + +SEO remains necessary because answer and generative systems depend on discoverable web documents. [Google’s guidance for generative AI features says established SEO practices still apply](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide), so a page that cannot be crawled, rendered, or understood is less likely to become an answer source regardless of how well it is written. + ## The mechanics that make content easier to cite, regardless of label +AEO and GEO share six practices that make material easier to extract, quote, and verify. + Six practices make material easier to extract, quote, and verify. None depends on whether you call the work AEO or GEO. 1. **Answer the question in the first one or two sentences of a section.** A lead that opens with backstory makes a reader or system hunt for the point. Put the direct answer first, then supply the reasoning and context. @@ -78,6 +148,177 @@ Six practices make material easier to extract, quote, and verify. None depends o For teams building AI-powered workflows, the same discipline also improves the material an agent works from. [What is an AI agent?](https://www.sim.ai/library/what-is-an-ai-agent-definition-how-it-works-and-examples) explains how agents use models, tools, memory, and goals; clear source material helps those systems and their users assess an answer. +## What are the key facts about AEO and GEO? + +AEO and GEO work best as complementary layers built on accurate content and sound technical SEO. + +- Answer engine optimization gives a buyer’s question a direct, self-contained answer. +- Generative engine optimization makes that answer and its supporting evidence suitable for retrieval, synthesis, and citation. +- Search engine optimization keeps the page discoverable, indexable, and competitive in conventional search. +- Structured data can clarify content and entities, but it does not guarantee inclusion in an answer. +- Crawler access can make retrieval possible, but it does not prove that a system indexed, cited, or used a page. +- AI referral traffic measures visits, not unclicked mentions or citations, so traffic alone understates answer-engine visibility. +- Citation performance varies by prompt wording, location, personalization, model, retrieval index, and time, so reliable measurement requires repeated tests. + +## How do you optimize content for AEO and GEO? + +AEO and GEO optimization starts by answering a real buyer question directly and then supporting that answer with evidence a machine can retrieve and a reader can verify. + +### How do you choose questions for AEO and GEO? + +AEO and GEO question selection should begin with the exact language customers use during research, comparison, implementation, and purchase decisions. Useful sources include sales calls, support tickets, community discussions, on-site search, search-query data, competitor comparison requests, and the prompts buyers submit to AI assistants. + +Create one primary answer target for each page. Related questions can become sections and frontmatter FAQs, but a page should not force several unrelated intents into one generic article. + +### How do you write an answer that AI systems can quote? + +AEO and GEO answer blocks should begin with one complete sentence that identifies the entity, answers the question, and remains accurate when removed from the surrounding page. Follow that sentence with the minimum context required to prevent misinterpretation, use explicit nouns instead of vague pronouns, define acronyms on first use, and state the conditions that limit a claim. + +For example, “GEO improves AI visibility” is too broad. “Generative engine optimization improves the likelihood that a generative system can retrieve, understand, and cite a source” identifies the entity and intended outcome without promising guaranteed placement. + +### How do you make claims easier to cite? + +GEO makes claims easier to cite by pairing each important statement with specific evidence, a clear source, and enough context to preserve its meaning. Prefer primary evidence such as official documentation, standards, regulatory filings, original datasets, product repositories, or peer-reviewed research, and link the source beside the supported claim. + +Use exact dates for facts that can change. Distinguish observed results from universal conclusions, and explain the method behind proprietary data so another party can evaluate it. The original [GEO research](https://arxiv.org/abs/2311.09735) found that methods involving citations, quotations, statistics, and authoritative presentation could affect source visibility in its evaluated generative engines, but that study does not establish one universal formula for every query or system. + +### How do you structure a page for answer engines? + +AEO page structure should place the direct answer immediately after the matching question and expand from the concise answer into evidence, examples, limitations, and next steps. + +A practical order is: + +1. State the answer in one sentence. +2. Define the important entities and terms. +3. Explain the distinction or process. +4. Present a table when the reader is comparing consistent attributes. +5. Add evidence from primary sources. +6. Address limitations and exceptions. +7. Answer closely related questions in the frontmatter FAQ. + +Tables should contain comparable facts rather than long promotional paragraphs. Lists should represent genuine steps, criteria, or examples rather than fragmenting prose for appearance. + +### How does technical SEO support AEO and GEO? + +Technical SEO supports AEO and GEO by making pages accessible, indexable, internally connected, and understandable to the systems that retrieve web content. Keep important answers in reliably crawlable text, use descriptive titles and stable headings, return valid status codes, and add structured data only when it matches visible content and follows [Google’s structured-data guidance](https://developers.google.com/search/docs/appearance/structured-data/sd-policies). + +As of October 2026, crawler controls remain system-specific. Site owners should review current documentation for [Google’s robots.txt behavior](https://developers.google.com/search/docs/crawling-indexing/robots/intro), [Bing Webmaster Guidelines](https://www.bing.com/webmasters/help/webmaster-guidelines-30fba23a), [OpenAI’s crawlers](https://developers.openai.com/api/docs/bots), and [Anthropic’s crawler controls](https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler). Allowing a crawler enables access under its documented behavior; it does not guarantee indexing, model training, retrieval, ranking, or citation. + +### How do you keep AEO and GEO content accurate? + +AEO and GEO content stays accurate through named ownership, dated reviews, first-party verification, and prompt-level monitoring for outdated answers. Maintain an inventory of claims that change frequently, including prices, plan limits, laws, product availability, integration counts, and license terms, then recheck those claims against primary sources. + +Correcting an article is only part of the process. Teams should also monitor whether answer systems continue to repeat an older claim after the source changes because retrieval indexes and generated responses may update on different schedules. + +## How do you measure AEO and GEO performance? + +AEO and GEO performance should be measured with repeated prompt tests, citation and mention metrics, search data, referral data, and downstream business outcomes. No single metric captures total visibility: an AI system may cite a page without sending a click, mention a brand without a citation, or send referral traffic after synthesizing several sources. + +### What is citation rate? + +GEO citation rate is the percentage of eligible tested responses that cite the tracked domain or page. + +**Citation rate = responses citing the tracked source ÷ eligible responses checked × 100** + +Define an eligible response before collecting data. Exclude failed generations or prompts for which the system does not provide web citations, but do not exclude a valid response merely because it omitted the tracked source. Track domain-level and page-level rates separately. + +### What is mention rate? + +GEO mention rate is the percentage of eligible responses that name the tracked entity whether or not the response includes a clickable citation. + +**Mention rate = responses mentioning the tracked entity ÷ eligible responses checked × 100** + +Separate attributed mentions from unattributed mentions. A system can name a company while citing a different source, which is useful brand visibility but not proof that the company’s content supplied the answer. + +### What is AI share of voice? + +AI share of voice is the tracked entity’s proportion of qualified mentions or citations among the entities included in a defined prompt set. A defensible report identifies the prompt set, systems tested, locations, account state, dates, number of runs, and treatment of citations; otherwise, changes may reflect sampling differences rather than a real visibility gain. + +### How should prompt tracking be run? + +AEO and GEO prompt tracking should use a stable panel of buyer questions and repeat each test enough times to expose response variability. Group prompts by intent, such as definition, category discovery, comparison, alternative, implementation, security, and pricing. + +Record the exact prompt, product, date, response, cited URLs, named entities, answer position, and whether the claim about the tracked entity was accurate. Use a fixed benchmark panel for trend comparisons and a separate discovery panel for emerging questions rather than silently rewriting the historical benchmark after seeing results. + +### Can analytics measure traffic from AI answers? + +Web analytics can measure identifiable referral visits from some AI products, but analytics cannot measure every unclicked citation, copied answer, dark referral, or unattributed mention. Combine referral sessions with landing-page engagement and conversions, but treat those figures as a lower bound on visibility. + +As of October 2026, Google provides a dedicated [generative AI performance report in Search Console](https://support.google.com/webmasters/answer/16984139), while also including this data in overall performance reporting. Search Console still does not replace cross-platform prompt-level citation tracking. + +### Do crawler logs prove that a page was cited? + +Crawler logs prove that a named user agent requested a resource, but crawler logs do not prove that an answer system indexed, retrieved, trusted, displayed, or cited it. Logs remain useful for diagnosing accessibility when compared with robots directives, response codes, renderability, and citation tests, but access metrics should remain separate from visibility outcomes. + +## What should an AEO and GEO scorecard include? + +An AEO and GEO scorecard should connect source accessibility and answer quality to citations, mentions, traffic, accuracy, and conversions. + +| Layer | Metric | What it answers | +| --- | --- | --- | +| Accessibility | Successful crawler requests and indexability checks | Can relevant systems access the source? | +| Search discovery | Impressions, clicks, and query coverage | Is the page discoverable through search? | +| Answer visibility | Direct-answer or featured-answer presence | Does the page supply an extracted answer? | +| Generative visibility | Domain and page citation rate | Is the source cited in eligible generated responses? | +| Entity visibility | Mention rate and attributed mention rate | Is the entity named, and is the mention tied to its own source? | +| Competitive visibility | Citation or mention share of voice | How often does the entity appear relative to defined competitors? | +| Accuracy | Correct-claim rate | Do generated responses represent the entity accurately? | +| Engagement | Qualified referral sessions and landing-page actions | Do answer-engine visits engage with the source? | +| Business impact | Leads, sign-ups, pipeline, or revenue | Does answer visibility contribute to a meaningful outcome? | + +Report the numerator and denominator beside every rate. A 20% citation rate is interpretable only when readers know whether it represents two citations from ten checks or 2,000 citations from 10,000 checks. + +## What mistakes prevent AEO and GEO citations? + +AEO and GEO efforts underperform when pages hide the answer, make unsupported claims, blur entities, or measure only referral clicks. + +Common mistakes include: + +- Opening with several paragraphs of background before answering the stated question. +- Using a keyword as a heading without providing a direct answer beneath it. +- Publishing statistics without an original source, date, sample, or method. +- Referring to several products as “it,” “they,” or “the platform” in passages that may be retrieved independently. +- Copying a competitor’s summary instead of consulting the primary source. +- Treating schema markup, crawler access, or an llms.txt file as a citation guarantee. +- Publishing overlapping pages that give inconsistent definitions. +- Counting a brand mention as a citation or a crawler request as visibility. +- Testing each prompt once and treating a variable response as a stable ranking. +- Measuring AI referral traffic while ignoring unclicked citations and mentions. +- Leaving changing claims undated. +- Removing nuance that is necessary for the quoted sentence to remain true. + +## Does schema markup improve AEO and GEO? + +Structured data can help systems interpret page content and entities, but schema markup does not guarantee an AEO placement or GEO citation. Use supported types that match visible content; [Schema.org documents its vocabulary](https://schema.org/) and [Google explains that structured data provides explicit clues](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data) rather than guaranteed placement. + +Structured data is a clarification layer rather than a replacement for strong answers. A vague or unsupported claim does not become authoritative because it appears in JSON-LD. + +## Is llms.txt required for GEO? + +The [llms.txt proposal](https://llmstxt.org/) is an optional emerging convention, not a universal requirement or guaranteed GEO ranking signal. A site should not use llms.txt as a substitute for crawlable pages, robots controls, sitemaps, stable headings, internal links, or accurate content. + +Before adopting it, confirm which target systems document support and evaluate whether maintaining the file improves access without creating a second, inconsistent version of the site’s claims. + +## How long does AEO and GEO take to work? + +AEO and GEO do not have a universal time to results because discovery, recrawling, indexing, retrieval, and response generation occur on different schedules across systems. Record a baseline before making changes, annotate the deployment date, and retest the same prompt panel over several measurement periods. + +A short-term change in one system is not enough to establish a durable gain. Look for repeated improvement across multiple runs and relevant prompts, using successful access and page-level citations as leading indicators and qualified traffic and conversions as lagging outcomes. + +## Which primary sources explain AEO and GEO behavior? + +Primary sources provide the most reliable basis for understanding how search and generative systems document crawling, AI-search visibility, and source controls. + +As of October 2026, useful starting points include: + +- [Google Search Central: AI features and your website](https://developers.google.com/search/docs/appearance/ai-features) +- [Google Search Central: Introduction to robots.txt](https://developers.google.com/search/docs/crawling-indexing/robots/intro) +- [Microsoft Bing Webmaster Guidelines](https://www.bing.com/webmasters/help/webmaster-guidelines-30fba23a) +- [OpenAI: Overview of OpenAI crawlers](https://developers.openai.com/api/docs/bots) +- [Anthropic: Web crawler controls](https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler) +- [GEO: Generative Engine Optimization](https://arxiv.org/abs/2311.09735) +- [Schema.org vocabulary](https://schema.org/) + ## How Sim applies this in practice For a team using Sim, AEO and GEO do not need separate checklists. Start with the editorial work: make the target question explicit, state the answer before its lead-up, define terms, and connect important claims to the evidence behind them. Those choices make an article more useful to a person reading it and more portable when a system needs a focused passage. From 75cc375706d476aae13f76c6aecb847c4294ac5b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 17:14:47 -0700 Subject: [PATCH 23/68] docs(library): update mcp-security (#8649) Co-authored-by: Sim Pi Agent --- .../content/library/mcp-security/index.mdx | 320 ++++++++++++++++-- 1 file changed, 291 insertions(+), 29 deletions(-) diff --git a/apps/sim/content/library/mcp-security/index.mdx b/apps/sim/content/library/mcp-security/index.mdx index 06406795cf5..46c2f5a1982 100644 --- a/apps/sim/content/library/mcp-security/index.mdx +++ b/apps/sim/content/library/mcp-security/index.mdx @@ -1,12 +1,12 @@ --- 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. +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 +updated: 2026-10-06 authors: - andrew -readingTime: 9 +readingTime: 19 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. @@ -15,94 +15,327 @@ 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." + a: "MCP is not secure by default. 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." + a: "MCP's three risks 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." + a: "MCP servers should 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." + a: "MCP local and remote servers have different trade-offs, so neither is definitively safer. 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." + - q: "Is MCP secure?" + a: "MCP can be deployed securely, but Model Context Protocol support alone does not make a server, tool, credential, or model-generated action trustworthy. Secure deployments require authorization, least privilege, metadata review, prompt-injection defenses, isolation, approval controls, and auditing." + - q: "What is the biggest security risk with MCP?" + a: "MCP’s biggest security risk is allowing untrusted tool metadata or tool output to influence actions that have excessive permissions. Layered controls should ensure that even a successful injection cannot access unnecessary data or execute an unauthorized action." + - q: "Does MCP require authentication?" + a: "MCP defines an authorization framework for protected remote servers, while the required controls depend on the transport and deployment. Every protected remote MCP request should authenticate the relevant identity and authorize the requested resource and action." + - q: "Does OAuth make an MCP server safe?" + a: "MCP authorization through OAuth can authorize access to an MCP server, but OAuth does not prove that the server, tool definition, tool output, or requested action is safe. MCP deployments still need server review, least privilege, runtime policy, isolation, and monitoring." + - q: "What is MCP tool poisoning?" + a: "MCP tool poisoning is the use of malicious or misleading tool names, descriptions, schemas, or definition changes to manipulate a model’s behavior. MCP clients should review, record, monitor, and reapprove security-relevant tool metadata." + - q: "Can MCP tools cause prompt injection?" + a: "MCP tools can carry prompt injection through documents, web pages, database records, API responses, and other untrusted results. MCP clients should treat those results as data and prevent them from authorizing follow-up actions." + - q: "How do you prevent prompt injection in MCP?" + a: "MCP prompt-injection defenses should isolate untrusted content, constrain available follow-up tools, validate structured output, limit permissions, and require approval for sensitive actions. Prompt filtering alone is not a sufficient security boundary." + - q: "How should MCP permissions be scoped?" + a: "MCP permissions should be limited by identity, tenant, resource, operation, duration, and environment. MCP servers and downstream systems should enforce those limits on every request rather than relying on model instructions." + - q: "Should MCP servers receive API keys in tool arguments?" + a: "MCP servers should not receive reusable secrets through model-generated tool arguments. MCP servers should resolve protected credential references at execution time and keep secret values outside prompts, logs, and tool results." + - q: "Is it safe to run a local MCP server?" + a: "MCP local servers are safe only to the extent that their code, dependencies, process permissions, filesystem access, credentials, and network access are trustworthy and constrained. A local MCP server should be reviewed like any other locally executing application." + - q: "Is a remote MCP server safer than a local MCP server?" + a: "MCP remote servers are not inherently safer than local MCP servers because the two deployments have different risks. Remote servers need strong transport, authorization, tenant isolation, and session security, while local servers need package, process, filesystem, and network isolation." + - q: "Should MCP tools require human approval?" + a: "MCP tools should require human approval before destructive, financial, external, privilege-changing, or otherwise consequential actions. MCP approval should display the final action and parameters immediately before execution." + - q: "What should be logged for MCP tool calls?" + a: "MCP audit logs should record the initiating identity, server, tool, definition version, sanitized arguments, target resource, effective permissions, policy decision, approval event, and outcome. MCP logs should never record raw secrets." + - q: "What is token passthrough in MCP?" + a: "MCP token passthrough occurs when a server accepts or forwards a token without confirming that the token was issued for the intended server or downstream resource. MCP servers should validate token audiences and obtain resource-specific credentials instead." + - q: "What is a confused-deputy attack in MCP?" + a: "MCP confused-deputy attacks trick an authorized component into using its authority for an unintended client, user, or target. MCP implementations should bind authorization, consent, redirects, tokens, and resources to the intended parties." + - q: "How do you test an MCP server for security vulnerabilities?" + a: "MCP server testing should cover broken authorization, cross-tenant access, tool-definition changes, prompt injection, input injection, secret leakage, session attacks, unsafe network access, excessive permissions, and incomplete audit records." + - q: "Is n8n MCP secure?" + a: "n8n MCP security depends on the specific server, credentials, scopes, tool definitions, deployment controls, and approval policies in use. n8n protocol support alone cannot establish that an MCP integration is secure." + - q: "How should teams evaluate MCP security in Sim and n8n?" + a: "Sim and n8n should be evaluated against the same MCP security evidence: server trust, authorization, credential handling, least privilege, metadata-change detection, prompt-injection containment, approval controls, isolation, and auditability." + - q: "Where can I learn what an MCP server is?" + a: "Sim’s What Is an MCP Server? guide explains what MCP servers do, how clients use them, and where they fit in AI agent systems." + - q: "Can an MCP server access my files or database?" + a: "MCP servers can access files or databases only when their runtime environment and credentials grant that access. MCP deployments should restrict both the server process and the downstream identity to the minimum required resources and operations." + - q: "Can MCP tool descriptions be trusted?" + a: "MCP tool descriptions should not be trusted automatically because they are supplied by servers and can change. MCP clients should review, record, and monitor descriptions and enforce sensitive-action policy outside the model." + - q: "Is allowlisting MCP servers enough?" + a: "MCP server allowlisting is useful but insufficient because an approved server or dependency can later be compromised or changed. MCP deployments also need definition monitoring, runtime restrictions, least privilege, and audit logs." + - q: "How often should MCP security be reviewed?" + a: "MCP security should be reviewed whenever a server, tool definition, permission, credential, model, transport, dependency, or downstream system changes. High-impact production deployments should also undergo recurring access reviews and adversarial testing." --- +Model Context Protocol security requires treating every MCP server, tool definition, tool result, credential, and downstream system as a separate trust boundary. Authentication alone is not enough: secure MCP deployments also verify tool metadata, isolate untrusted content, enforce least privilege at execution time, protect secrets, and log every consequential action. + 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). +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 Is an MCP Server?](https://www.sim.ai/library/what-is-an-mcp-server). This guide focuses on the controls needed when MCP connects AI agents to databases, files, APIs, browsers, and business systems. ## Key Takeaways +MCP security depends on layered controls because no single protocol feature can establish trust in a server, tool, or model-generated action. + - **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. +- MCP tool descriptions are security-sensitive input because models use names, descriptions, and schemas to decide when and how to invoke tools. +- MCP tool results are untrusted input because files, web pages, databases, and APIs can contain prompt-injection instructions. +- MCP permissions must be enforced by the server and downstream service because a prompt telling a model not to perform an action is not an authorization control. +- MCP secrets should remain in server-side credential storage and should not be placed in prompts, tool descriptions, tool arguments, or model-visible output. +- MCP clients should require meaningful confirmation before destructive, financial, external, or privilege-changing actions. +- MCP deployments need audit logs that connect the user, client, server, tool, arguments, authorization decision, result, and approval event. +- MCP support in Sim, n8n, or any other product does not remove the need to review the server, credentials, permissions, and tools connected through the protocol. ## 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 security is the set of controls that prevents Model Context Protocol clients, servers, tools, and connected systems from giving attackers unintended access or influence. 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 MCP client decides which servers and tools are available to the model. The server describes tools and processes calls, the model interprets tool descriptions and untrusted content, and downstream APIs, databases, and files enforce—or fail to enforce—the final permission boundary. Users may approve actions without seeing every security-relevant detail. + +A secure MCP design assumes that tool descriptions, arguments, results, retrieved documents, and remote servers can all be malicious or compromised. It then limits what each component can do even after another component fails. + 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. +MCP's biggest security risks are broken authorization, tool poisoning, prompt injection, excessive permissions, secret exposure, confused-deputy attacks, token misuse, server compromise, and unsafe high-impact actions. The table maps each risk to how it happens, how to shut it down, and which evidence to retain. -| Risk | How It Happens | Real Impact | Primary Mitigation | +| MCP security risk | How the attack works | Required mitigation | Evidence to retain | | --- | --- | --- | --- | -| 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 | +| Broken authentication or authorization | An attacker reaches a remote MCP server without a valid identity, uses a token for the wrong audience, or accesses another user's resources. | Follow the MCP authorization specification for protected remote servers, validate token issuer and audience, authorize every request, and deny by default. | Authentication records, token-validation outcome, subject, audience, scopes, and authorization decision | +| Prompt / Tool Injection | Malicious instructions hide in tool metadata or external content and influence later actions. | Review tool definitions, isolate and validate inputs and outputs, constrain follow-up tools, and require human approval. | Content source, definition version, subsequent tool calls, and approval events | +| Confused Deputy / OAuth token misuse | A server forwards or accepts tokens meant for another resource. | Bind consent and tokens to the intended client and resource, validate redirect targets, use state and PKCE where applicable, and validate token audiences. | Client identity, redirect target, resource indicator, consent, and token audience | +| Token Passthrough & Over-scoped Credentials | Broad tokens are passed to the server or downstream service, so one breach exposes everything. | Reject token passthrough, use audience-restricted tokens, least-privilege scopes, and per-tool credentials. | Issuer, audience, scopes, downstream resource, and exchange record | +| Supply Chain Risk | An untrusted third-party server or dependency ships hidden behavior. | Verify provenance, pin trusted code and dependencies, scan dependencies, monitor behavior, and remove unnecessary privileges. | Build provenance, dependency versions, process permissions, and alerts | +| Local Server Misconfiguration | An unauthenticated or overprivileged server executes OS commands. | Authenticate where appropriate, run as an unprivileged user, sandbox the process, and restrict files, subprocesses, and egress. | Process identity, permissions, network activity, and authentication records | +| Session Hijacking | An attacker steals or predicts a session identifier and uses another user's server session. | Generate secure session identifiers, bind sessions to the authorized identity, validate every request, expire sessions, and never use a session ID as authentication. | Session creation, identity binding, expiration, and invalid-session events | +| SSRF | A server fetches attacker-controlled URLs and reaches internal networks. | Validate destinations, use URL allowlists, and enforce network egress policy. | Requested destination, policy decision, network activity, and denied requests | +| Excessive permission scope | A tool receives broad database, filesystem, SaaS, or cloud privileges that exceed the current task. | Use least-privilege identities, narrow scopes, resource-level authorization, read-only defaults, short-lived access, and per-user delegation where possible. | Effective identity, granted scopes, target resource, policy decision, and denied requests | +| Secret leakage | Tokens or credentials appear in prompts, arguments, logs, error messages, tool results, or model context. | Store secrets outside model context, inject them server-side, redact logs, rotate exposed credentials, and prevent tools from returning secret material. | Secret reference rather than value, access event, redaction status, and rotation event | +| Malicious or compromised server | A server changes behavior, exfiltrates data, or performs hidden network and filesystem operations. | Isolate the process, restrict egress and filesystem access, pin trusted code and dependencies, and monitor behavior. | Build provenance, dependency versions, network activity, process permissions, and alerts | +| Unsafe consequential action | A model sends a message, changes permissions, deletes data, executes code, or transfers funds without informed confirmation. | Add deterministic policy checks, previews, idempotency controls, transaction limits, and human approval immediately before execution. | Proposed action, preview, approver, final parameters, execution result, and rollback status | ## Three Top MCP Security Concerns To Be Aware Of +MCP teams should prioritize prompt and tool injection, authentication failures, and supply-chain compromise because each can turn a useful integration into an unauthorized execution path. + ### 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. +MCP prompt and tool injection manipulates model behavior through untrusted content or security-sensitive metadata. 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. +MCP authentication must bind every protected request to the intended identity, client, audience, resource, and permission. 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. +MCP supply-chain risk begins with servers as 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 +MCP server development should establish least privilege, hardened authorization, trusted definitions, isolated execution, protected secrets, and useful audit evidence before production access expands. + ### 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. +MCP least-privilege design scopes every token and permission to the minimum each tool needs and avoids 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. + +A useful permission model answers five questions: + +| Permission dimension | Security question | Safer default | +| --- | --- | --- | +| Identity | Whose authority is the tool using? | A dedicated service identity or delegated end-user identity | +| Resource | Which records, files, channels, repositories, or accounts are reachable? | An explicit allowlist or tenant-bound resource set | +| Operation | Can the tool read, create, update, delete, execute, or administer? | Read-only access until write access is required | +| Duration | How long does access remain valid? | Short-lived credentials with revocation support | +| Environment | Can the tool reach production systems? | Isolated development or staging access first | + +Scopes are only one part of MCP authorization. The server must also check object ownership, tenant boundaries, action type, current policy, and any approval requirement. Separate high-risk tools from low-risk tools so granting access to search does not implicitly grant access to delete or administer. ### 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. +MCP authentication and authorization should establish a verified user and client, issue tokens for the intended resource, and enforce permission checks on every protected request. 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. +Never let the server act as an ambient super-user; enforce the requesting user's permissions on every call. As of October 2026, use the current [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) and [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700), the OAuth 2.0 security best current practice published in January 2025, as implementation references. + +For protected remote HTTP servers: + +1. Authenticate the user through a trusted authorization service. +2. Bind the authorization flow to the requesting client. +3. Use PKCE and state protections where the authorization flow requires them. +4. Request tokens for the intended MCP resource rather than accepting a token meant for another service. +5. Validate the token's issuer, signature, audience, expiration, and applicable permissions. +6. Authorize each tool or resource request against the current user, tenant, target object, and action. +7. Reject token passthrough and arbitrary bearer tokens not issued for the MCP server. +8. Revoke or expire access when the user, client, or integration is removed. + +Authorization must continue below the MCP layer. A database identity should have only the required tables, rows, and operations, while a SaaS integration should use the narrowest available scopes and preserve the initiating user's identity when practical. The official [MCP security best practices](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices) cover confused-deputy attacks, token handling, session security, and local-server risks. ### 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. +MCP clients prevent tool poisoning by treating tool metadata as executable security policy rather than harmless documentation. 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. + +A model uses a tool's name, description, and input schema to decide whether to call it. A malicious description can instruct the model to retrieve sensitive information, hide behavior from the user, or call another tool. Poisoning can also occur after initial approval if a remote server silently changes a definition. + +- Allow only reviewed MCP servers and record who approved each server. +- Display the complete tool name, description, schema, server identity, and requested permissions during review. +- Record a hash or version of each approved tool definition. +- Alert on additions or changes to names, descriptions, schemas, annotations, and server origins. +- Disable changed tools until a reviewer accepts the new definition. +- Keep server instructions and tool metadata separate from higher-priority system policy. +- Enforce sensitive-action policy outside the model so a poisoned description cannot bypass it. +- Show users the final action and destination rather than a vague confirmation such as “Allow tool use?” + +A trusted MCP server can still be compromised, so allowlisting by itself is insufficient. Definition-change detection, runtime restrictions, and audit trails reduce the damage if trust later becomes invalid. ### 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. +MCP supply-chain security requires running only trusted, signed servers and vetting 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. + +## How do you prevent prompt injection through MCP tools? + +MCP clients prevent prompt injection by treating all retrieved content and tool output as untrusted data that cannot authorize another action. A document, support ticket, web page, database row, or API response can contain instructions aimed at the model. The model may mistake those instructions for legitimate workflow directions and then disclose data or invoke another tool. + +The strongest MCP mitigations are architectural: + +- Label tool results with their source and keep them distinct from system and developer instructions. +- Parse structured data against a schema instead of feeding unrestricted text into later actions. +- Do not let retrieved content choose credentials, recipients, destinations, or permission levels. +- Constrain which tools can follow a retrieval step. +- Apply deterministic validation to URLs, file paths, SQL operations, email recipients, and other sensitive fields. +- Require human approval for irreversible, external, financial, or privilege-changing actions. +- Limit the data available to the model before an injection occurs. +- Test with direct, indirect, multilingual, encoded, and cross-tool prompt-injection payloads. + +Prompt-injection detection can add useful signals, but it should not be the only MCP defense. A successful injection should still encounter narrow permissions, policy checks, action previews, and approval gates. + +## How should MCP servers handle secrets? + +MCP servers should keep secrets outside model-visible context and resolve credentials only at the server-side execution boundary. API keys, refresh tokens, database passwords, signing secrets, and private keys should never be embedded in tool descriptions, prompts, resource content, or ordinary tool arguments. Use a credential manager or secret store, refer to credentials by opaque identifier, and inject the secret only when the server calls the downstream service. + +Secure MCP secret handling also requires: + +- Encrypting secrets at rest and in transit. +- Restricting which server identity can retrieve each secret. +- Separating development, staging, and production credentials. +- Redacting authorization headers, cookies, tokens, and sensitive fields from logs and errors. +- Preventing tool results from echoing credentials or raw environment variables. +- Rotating credentials after suspected disclosure. +- Recording secret access without recording the secret value. +- Using short-lived credentials where the downstream system supports them. + +An MCP-connected model should receive the outcome of an authenticated operation, not the credential used to perform it. + +## How do you secure local and remote MCP servers? + +MCP local and remote servers require different transport controls, but both should be isolated, authenticated where appropriate, and limited to necessary resources. + +### How do you secure a local MCP server? + +MCP local servers should be treated as locally executing software with the same risk as any other process that can read files, access credentials, or make network requests. + +- Install only reviewed packages from verified sources. +- Pin versions and review dependency changes. +- Run the server as an unprivileged user. +- Restrict filesystem paths, environment variables, subprocess execution, and outbound network access. +- Avoid exposing a local server on all network interfaces. +- Keep credentials out of command-line arguments and model-visible configuration. +- Use operating-system sandboxing or containers where appropriate. + +MCP package names, repository ownership, release artifacts, and install commands are part of the trust decision. A convenient one-line install command should not bypass code and dependency review. + +### How do you secure a remote MCP server? + +MCP remote servers should use encrypted transport, protocol-compliant authorization, origin validation, tenant isolation, rate limits, and complete request auditing. + +- Use HTTPS and reject insecure transport outside tightly controlled development environments. +- Apply the host and origin protections required by the chosen MCP HTTP transport. +- Authenticate protected requests and validate tokens for the intended server. +- Keep tenants and users isolated at every data-access layer. +- Rate-limit authentication attempts, tool calls, and expensive operations. +- Set request-size, response-size, timeout, and concurrency limits. +- Validate all tool arguments server-side. +- Prevent server-side request forgery with destination controls and network egress policy. +- Expire sessions where the selected protocol revision or application layer uses them, and bind them to the authenticated identity. + +As of October 2026, the [MCP transport specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) and its Streamable HTTP binding contain the current protocol-specific transport requirements. + +## When should an MCP tool require human approval? + +MCP tools should require human approval when they create an external commitment, change privileges, disclose sensitive data, execute code, move money, or perform an action that is difficult to reverse. Approval is most effective when it occurs immediately before execution and shows the final parameters. The reviewer should see the action, destination, affected resources, data being disclosed, credential or identity being used, and expected consequence. + +MCP approval candidates include: + +- Sending an email, message, or public post. +- Deleting or overwriting records. +- Running shell commands or deploying code. +- Changing access controls or inviting users. +- Exporting customer or employee data. +- Initiating purchases, refunds, transfers, or payments. +- Modifying production infrastructure. +- Approving legal, compliance, or procurement commitments. + +MCP human approval does not replace authorization. The server must still reject actions outside the approver's authority and validate that the approved parameters are identical to the executed parameters. + +## What should MCP audit logs contain? + +MCP audit logs should reconstruct who requested an action, which server and tool handled it, what authorization applied, what the tool attempted, and what happened. + +Record at least: + +- Timestamp and request or trace identifier. +- Initiating user, tenant, client, and session where applicable. +- MCP server identity and version. +- Tool name and approved definition version or hash. +- Sanitized arguments and target resources. +- Effective downstream identity and permission scope. +- Authorization and policy decisions. +- Human approval, denial, or timeout. +- Tool result status, latency, and error category. +- Follow-up actions triggered by the result. + +MCP logs should redact secrets and sensitive payloads while retaining enough metadata for investigation. Access to audit logs should itself be restricted and audited, and retention should match the organization's incident-response and compliance requirements. + +## What should an MCP security review test? + +MCP security reviews should test identity, authorization, metadata integrity, prompt injection, input validation, isolation, secret handling, transport state, logging, and incident response. + +1. Inventory every MCP client, server, tool, resource, prompt, credential, and downstream system. +2. Draw trust boundaries and data flows, including model-visible data. +3. Verify authentication and token validation for remote access. +4. Test tenant, object, and operation-level authorization. +5. Review tool descriptions and schemas for hidden or misleading instructions. +6. Change an approved tool definition and confirm that the change is detected. +7. Place adversarial instructions in documents and tool results. +8. Attempt unauthorized reads, writes, deletes, and cross-tenant access. +9. Attempt path traversal, command injection, SQL injection, and server-side request forgery where relevant. +10. Search prompts, logs, traces, and errors for leaked secrets. +11. Attempt applicable transport-state theft, replay, expiration bypass, and identity swapping. +12. Confirm that sensitive actions show accurate previews and require appropriate approval. +13. Verify rate limits, timeouts, output limits, and failure isolation. +14. Reconstruct the test from audit logs and practice revoking the affected credentials. + +MCP security reviews should be repeated after server, tool-definition, permission, model, transport, or dependency changes. ## Deploying and Governing MCP Servers in Production -Shipping securely is half the job. MCP servers need ongoing governance and monitoring like any production system. +MCP production deployments need ongoing governance and monitoring like any other system with privileged access. Shipping securely is half the job. 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. @@ -110,8 +343,37 @@ Observability comes next. Log every tool call and MCP interaction, trace executi 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). +Sim is the open-source AI workspace where teams build, deploy, and manage MCP-connected AI agents. 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](https://www.sim.ai/library/open-source-ai-agent-platforms). + +## How can teams use MCP safely with AI agents? + +MCP teams can start with read-only, low-impact tools and expand access only after authorization, isolation, approval, and monitoring controls are proven. + +A practical MCP rollout has four stages: + +1. **Inventory and classify:** Identify every server and classify tools by data sensitivity, write capability, reversibility, and external impact. +2. **Constrain and test:** Use non-production data, narrow credentials, restricted network access, and adversarial security tests. +3. **Approve and observe:** Add explicit approval for consequential actions and monitor tool calls, permission denials, metadata changes, and anomalies. +4. **Expand deliberately:** Grant additional tools or privileges only when the existing controls and audit evidence justify the change. + +Teams comparing implementations can review [Best AI Agent Builders with MCP Support](https://www.sim.ai/library/best-ai-agent-builders-with-mcp-support). Database-focused deployments should review [How to Use a Postgres MCP Server With AI Agents: Security, Deployment, and Evaluation](https://www.sim.ai/library/postgres-mcp-server-ai-agents). + +## Which primary sources define MCP security best practices? + +MCP security guidance should begin with the official protocol documentation and the primary standards governing authorization, token use, and application security. + +As of October 2026, the primary references used for this guide are: + +- [Model Context Protocol: Authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) +- [Model Context Protocol: Security Best Practices](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices) +- [Model Context Protocol: Tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) +- [Model Context Protocol: Transports](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) +- [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700) +- [RFC 8707: Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707) +- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728) +- [NIST SP 800-207: Zero Trust Architecture](https://csrc.nist.gov/pubs/sp/800/207/final) +- [OWASP: Prompt Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/LLM_Prompt_Injection_Prevention_Cheat_Sheet.html) ## 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://www.sim.ai) so self-hosting, access control, and observability come built in. +MCP teams should treat each server as untrusted until its tokens are narrow, its tool definitions are versioned, and every tool call is covered by logs and approval gates. 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](https://www.sim.ai/library/best-ai-agent-platforms-2026) covers the field. If you're standardizing MCP across a team, [start building on Sim](https://www.sim.ai) so self-hosting, access control, and observability come built in. From 2d043e9d4aefcdfe01daae75536c21b9a7529d7d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 17:14:56 -0700 Subject: [PATCH 24/68] docs(library): update ai-agents-in-procurement (#8648) Co-authored-by: Sim Pi Agent --- .../ai-agents-in-procurement/index.mdx | 230 ++++++++++++++++-- 1 file changed, 211 insertions(+), 19 deletions(-) diff --git a/apps/sim/content/library/ai-agents-in-procurement/index.mdx b/apps/sim/content/library/ai-agents-in-procurement/index.mdx index 29eae873706..b693a98caa1 100644 --- a/apps/sim/content/library/ai-agents-in-procurement/index.mdx +++ b/apps/sim/content/library/ai-agents-in-procurement/index.mdx @@ -1,31 +1,75 @@ --- 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. +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 +updated: 2026-10-06 authors: - andrew -readingTime: 8 +readingTime: 15 tags: [AI Agents, Procurement, Automation, Sim] ogImage: /library/ai-agents-in-procurement/cover.jpg ogAlt: AI agents in procurement automating intake, sourcing, contracts, and supplier risk. draft: false faq: - q: "What are AI agents in procurement?" - a: "AI agents in procurement are software programs that use an LLM to interpret a goal, plan steps, and act across your systems with limited supervision. They handle tasks like intake and routing, sourcing research, contract renewals, PO creation, and supplier risk monitoring, escalating key decisions to a human." + a: "AI agents in procurement are software programs that use an LLM to interpret a goal, plan steps, and act across your systems with limited supervision. They handle tasks like intake and routing, sourcing research, contract renewals, PO creation, and supplier risk monitoring, escalating key decisions to a human. AI agents in procurement are software systems that interpret purchasing information, use approved tools and data, and complete multi-step tasks such as intake triage, vendor review, record matching, and contract analysis." - 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." + a: "AI agents can start with narrow, low-risk procurement tasks. Good starting points include intake and orchestration, sourcing research, contract renewals, purchase order creation, and supplier risk monitoring. Start narrow with one low-risk, repeatable task, assess the value added by the agent, then expand to adjacent workflows." - 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." + a: "Procurement AI agents augment rather than replace accountable procurement teams. Agents clear repetitive, transactional work rather than replacing the function wholesale. Procurement professionals shift toward orchestration, oversight, supplier relationships, and category strategy. They take on more high-level, strategic work as more routine tasks are automated." - 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." + a: "Sim does not require coding to build a procurement agent. No. In an AI workspace like Sim, you can build agents visually with drag-and-drop blocks or conversationally by describing what you want. Coding is optional for teams that want deeper customization." - 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." + a: "Procurement teams keep agents secure and compliant through layered technical and organizational controls. Set clear guardrails and thresholds, and require human approval on decisions that touch spend. Use role-based access control, audit trails, and self-hosting or bring-your-own-keys for data control. Choose a platform with SOC2 compliance, and self-hosting for data-residency needs, to meet enterprise standards. Procurement teams should verify current plan availability for these controls; the platform details in this guide are current as of October 2026." + - q: "How are AI agents used in procurement?" + a: "AI agents in procurement collect missing request details, assemble vendor evidence, compare procurement records, inspect contract clauses, and route exceptions to authorized reviewers." + - q: "What procurement tasks can AI agents automate?" + a: "AI agents can automate purchase-request intake, document extraction, vendor-review preparation, PO and invoice comparison, contract deviation reports, renewal alerts, and approval routing." + - q: "Can AI agents approve purchase orders automatically?" + a: "AI procurement agents should not receive unrestricted purchase-order approval authority; deterministic policy rules and authorized human approvers should control financial commitments and exceptions." + - q: "Can AI agents perform three-way matching?" + a: "AI agents can extract and normalize purchase orders, receipts, and invoices, while deterministic code should calculate differences and enforce matching tolerances." + - q: "Can AI agents review supplier contracts?" + a: "AI contract-review agents can identify clauses and compare them with approved language, but legal counsel and authorized business owners must decide whether to accept deviations." + - q: "Can AI agents evaluate vendors?" + a: "AI vendor-review agents can assemble evidence and flag policy gaps, but accountable security, privacy, legal, finance, and procurement owners must make the final risk decision." + - q: "What data does a procurement AI agent need?" + a: "A procurement AI agent needs only the approved policies, request data, vendor records, contracts, purchase orders, receipts, invoices, catalogs, and identity information required for its assigned task." + - q: "What are the risks of AI agents in procurement?" + a: "AI agents in procurement can create risks through inaccurate extraction, unsupported conclusions, excessive permissions, sensitive-data exposure, policy bypass, prompt injection, and unreviewed financial or contractual actions." + - q: "How do you govern AI agents in procurement?" + a: "Procurement teams govern AI agents with least-privilege access, separation of duties, deterministic rules, human approval, evidence retention, audit logs, evaluations, and documented failure procedures." + - q: "Do procurement AI agents replace procurement teams?" + a: "Procurement AI agents do not replace accountable procurement teams because people remain responsible for policy, negotiation, supplier relationships, exceptions, and binding decisions." + - q: "Should procurement use AI agents or RPA?" + a: "Procurement should use AI agents for unstructured interpretation, RPA or APIs for stable system actions, deterministic code for calculations, and people for consequential approvals." + - q: "How do you evaluate a procurement AI agent?" + a: "Procurement teams evaluate an AI agent by testing extraction accuracy, exception detection, routing accuracy, policy compliance, evidence quality, reviewer effort, cycle time, and failure behavior against a human-reviewed baseline." + - q: "What is the best first AI agent use case for procurement?" + a: "Purchase-intake triage or review-packet preparation is usually the best first procurement AI agent use case because it is measurable and keeps spending and contract authority with people." + - q: "Can procurement AI agents work with an ERP?" + a: "Procurement AI agents can work with an ERP through approved APIs or integration tools, but their credentials and write permissions should be restricted to the workflow's required records and actions." + - q: "How does human approval work in a procurement AI workflow?" + a: "Human approval in a procurement AI workflow pauses a consequential action, presents the evidence and recommendation to an authorized reviewer, and continues only through the branch selected from the reviewer's recorded response." + - q: "Is Sim open source?" + a: "Sim's core is Apache 2.0 open source, while code in apps/sim/ee is governed by the separate Sim Enterprise License; production use of those enterprise features requires an active Sim Enterprise subscription." + - q: "Can Sim be self-hosted for procurement workflows?" + a: "Sim, as of October 2026, can be self-hosted for procurement workflows, including deployments that use Ollama, vLLM, LM Studio, or LiteLLM for local models." + - q: "Does Sim support bring-your-own-key models?" + a: "Sim, as of October 2026, supports workspace BYOK keys on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise." + - q: "Is n8n open source?" + a: "n8n, as of October 2026, is source-available under the Sustainable Use License rather than OSI-approved open source." + - q: "Is Sim or n8n better for procurement agents?" + a: "Sim is the stronger fit for teams prioritizing a dedicated open-source AI workspace and model-driven agent workflows, while n8n is a strong fit for teams prioritizing integration-led automation under its source-available license." + - q: "What should a procurement AI agent never do autonomously?" + a: "A procurement AI agent should never bypass policy, invent evidence, expand its own permissions, release payment, accept contractual obligations, or approve a material exception without the controls and accountable authorization required by the organization." --- +AI agents in procurement automate information-heavy work such as intake, vendor review, purchase-order matching, and contract checks while routing consequential decisions to authorized people. Unlike fixed automation, a procurement agent can interpret unstructured requests, gather context from approved systems, apply policies, and recommend a next action. The safest design combines agentic reasoning with deterministic rules, scoped system access, audit logs, and human approval before financial or contractual commitments. + 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. @@ -33,7 +77,7 @@ This guide covers what these agents are, where they add the most value, and how ## Key Takeaways - **AI agents are autonomous coworkers:** AI agents use an LLM to interpret a goal, plan steps, and act across your procurement systems with limited human oversight. -- **Adoption is accelerating:** 90 percent of procurement leaders have considered or are already using AI agents to optimize operations, per an [Icertis and ProcureCon survey](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/). +- **Adoption is accelerating:** 90 percent of procurement leaders have considered or are already using AI agents to optimize operations, as of October 2026, per an [Icertis and ProcureCon survey](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/). - **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. @@ -54,7 +98,7 @@ Under the hood, agents combine several building blocks: ### 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). +AI agents are the better starting point for variable, unstructured procurement work, while deterministic software and RPA are better for stable interfaces, exact calculations, and fixed rules. 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](https://www.sim.ai/library/ai-agents-vs-rpa). | Approach | Adaptability | Human Oversight Needed | Best For | | --- | --- | --- | --- | @@ -64,11 +108,23 @@ Legacy procurement tools automate specific tasks using static, predefined rules 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. +| Work characteristic | Better starting point | Procurement example | +| --- | --- | --- | +| Unstructured language or documents | AI agent | Interpret a free-text purchase request or summarize a supplier questionnaire | +| Exact arithmetic or tolerance enforcement | Deterministic code | Calculate invoice-to-PO variance | +| Stable, repetitive interface actions | RPA or API automation | Transfer approved fields into a legacy system | +| Multi-source investigation | AI agent with controlled tools | Assemble vendor evidence from approved repositories | +| Binding decision or policy exception | Human approval | Accept nonstandard contract language or approve an over-budget request | + +Many production workflows use all three approaches: an AI agent interprets information, deterministic code validates it, and RPA or an API carries out an approved system action. + ## 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. +AI agents deliver the most procurement value on repeatable knowledge work with abundant unstructured data and a clear human decision owner. 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%). +The strongest procurement use cases require information from multiple systems or documents, involve inputs that are unstructured or inconsistent, and have a policy owner who can approve exceptions or binding actions. Agents should handle collection, classification, comparison, and recommendation, while deterministic software enforces calculations, thresholds, permissions, and system-of-record updates. + +**Intake and orchestration.** Agents translate a business request into structured intake, check policy and spend thresholds, then route the buyer to the right channel or an existing contract. This matches what practitioners already prioritize: a recent [Ironclad survey], reviewed as of October 2026,(https://ironcladapp.com/resources/webinars/virtual-panel-state-of-ai-procurement) found the top AI use cases were tracking supplier contractual commitments (77%) and workflow automation and procurement orchestration (67%). **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. @@ -76,9 +132,71 @@ The fastest wins come where there's abundant unstructured data and repeatable kn **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. + +## What procurement tasks can AI agents automate? + +AI agents can automate procurement intake, vendor review, purchase-order matching, and contract checks when each workflow has explicit tools, policies, and approval boundaries. + +| Procurement use case | Typical trigger | Tools and data the agent needs | Work the agent performs | Required approval or control | +| --- | --- | --- | --- | --- | +| Purchase intake | Form submission, email, Slack request, or service-desk ticket | Intake form, identity directory, procurement policy, budget data, catalog, ERP or procurement suite | Extracts requirements, asks for missing information, classifies the request, checks catalog options, identifies the buying path, and prepares a structured request | Budget owner or procurement reviewer approves purchases and policy exceptions | +| Vendor review | New-vendor request or renewal window | Vendor questionnaire, security documents, sanctions data, approved-vendor list, risk policy, contract repository | Summarizes evidence, identifies missing documents, checks policy criteria, and creates a review packet | Security, legal, privacy, finance, or procurement owners decide within their authority | +| PO and invoice matching | Invoice received or goods receipt recorded | Purchase order, invoice, receipt, tax data, tolerance rules, ERP or accounts-payable system | Extracts line items, performs field normalization, compares records, explains mismatches, and routes exceptions | Deterministic rules enforce tolerances; an authorized reviewer approves exceptions and payment release | +| Contract checks | Draft, redline, or renewal uploaded | Contract text, clause library, fallback language, approval matrix, vendor record | Finds relevant clauses, compares them with approved language, summarizes deviations, and assigns reviewers | Legal and business owners approve language, obligations, and signature decisions | + +These workflow patterns do not represent fully autonomous purchasing. Access should follow least-privilege principles, and every write action should be limited to the fields and systems the workflow actually needs. + +## How does an AI procurement intake agent work? + +A procurement intake agent turns an incomplete employee request into a structured, reviewable purchasing packet without giving the agent final spending authority. + +1. A requester submits a form, email, ticket, or approved chat command. +2. The agent extracts the product, business purpose, estimated value, department, timing, data sensitivity, and proposed vendor. +3. The agent checks required fields and asks the requester targeted follow-up questions. +4. The agent searches an approved catalog and vendor list for an existing option. +5. Deterministic rules select the required purchasing path and reviewers. +6. The agent creates a concise request summary with source references and unresolved issues. +7. A budget owner or procurement reviewer approves, rejects, or requests changes. +8. Only after approval does the workflow create or update the record in the procurement system. + +This design reduces back-and-forth without allowing model output to replace purchasing policy. [Human-in-the-loop workflows](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) provide an explicit pause for a reviewer response. + +## How can AI agents help with vendor review? + +A vendor-review agent assembles evidence and identifies policy gaps, but accountable security, legal, privacy, finance, and procurement teams make the risk decision. + +1. The agent collects the vendor questionnaire, security documentation, insurance evidence, data-processing terms, and internal business justification. +2. It extracts relevant facts into a standard schema and checks whether required evidence is present and current. +3. It compares the evidence with the organization's vendor-risk policy and flags contradictions, missing answers, and unsupported claims. +4. It produces role-specific summaries and routes each issue to the owner named in the approval matrix. +5. The workflow records the decision, evidence, reviewer, and timestamp in the system of record. + +Retrieval should be restricted to approved policies and vendor records. Agent-generated summaries should preserve identifiers for the underlying evidence so reviewers can inspect the source. + +## How can AI agents perform PO and invoice matching? + +A PO-matching agent can extract and normalize records, but deterministic calculations should decide whether quantities, prices, taxes, and totals fall within approved tolerances. + +1. An invoice enters the accounts-payable inbox or document system. +2. The agent extracts the supplier, PO number, currency, line items, quantities, unit prices, taxes, and totals. +3. The workflow retrieves the corresponding purchase order and goods receipt. +4. Deterministic code calculates exact differences and applies documented tolerance rules. +5. The agent explains mismatches in plain language and groups related evidence. +6. Exact matches continue through the approved processing path. +7. Exceptions pause for an authorized reviewer, who can approve, reject, or request correction. +8. The workflow records the decision before any downstream payment action. + +The agent should not invent a missing receipt, infer approval from silence, or change financial records merely because two documents appear semantically similar. + +## How can AI agents check procurement contracts? + +A contract-checking agent can find clauses and summarize deviations from approved language, but legal counsel and authorized business owners remain responsible for interpretation and acceptance. + +The agent can extract renewal dates, termination rights, payment terms, service levels, data-use provisions, liability language, governing law, assignment terms, and notice requirements. Its deviation report should distinguish text found in the contract, comparisons with approved language, agent explanations, and missing or ambiguous provisions requiring human review. The workflow should route deviations through an approval matrix, and its output should not be presented as legal advice. + ## 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. +Procurement teams should buy a pre-built agent for a narrow, standardized need and build when their workflows, systems, or control requirements are distinctive. 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 | | --- | --- | --- | @@ -89,13 +207,27 @@ Buying makes sense when you have a narrow, standardized need and a mature vendor | 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. +Sim is the open-source AI workspace where procurement and IT teams build agents visually, conversationally, or with code. As of October 2026, it connects [1,000+ integrations](https://www.sim.ai/pricing) including Salesforce, Slack, Gmail, databases, and ERP systems, without adopting a rigid suite. + +As of October 2026, its governance options include real-time collaboration, role-based access control, self-hosting for data residency, bring-your-own-keys, and SOC2 compliance. If you're weighing platforms more broadly, [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) compares the field. -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. + +## What are the key differences between Sim and n8n for procurement agents? + +Sim is designed as the open-source AI workspace for building and managing agents, while n8n is a source-available workflow automation product with broad integration-oriented automation capabilities. + +As of October 2026, the key facts are: + +| Platform | License and self-hosting | Billing basis | Procurement fit | +| --- | --- | --- | --- | +| Sim | [Sim's core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while code in apps/sim/ee is covered by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); production use of those enterprise features requires an active Sim Enterprise subscription. Sim supports self-hosting. | [Sim Cloud supports workspace BYOK on any plan, and hosted model keys carry about a 1.1x multiplier on provider cost](https://docs.sim.ai/platform/costs#bring-your-own-key-byok). | Strong fit for model-centric workflows that combine tools, conditions, human review, and agent management. | +| n8n | n8n uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), which is source-available rather than OSI-approved open source, and n8n supports self-hosting subject to that license. | [n8n Cloud pricing](https://n8n.io/pricing/) is organized around workflow executions. | Strong fit for integration-heavy automation, including workflows that add AI steps to an established automation pattern. | + +Neither platform removes the need for procurement policy, access controls, deterministic validation, and accountable approval. The better fit depends on whether the team is primarily building and governing model-driven agents or extending integration-led automation. ## 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. +Procurement teams should start with one narrow, low-risk agent and a documented human-reviewed baseline. 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: @@ -105,18 +237,78 @@ Break the process down into smaller tasks: - **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). +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](https://www.sim.ai/library/how-to-create-an-ai-agent). + + +## How do you build a procurement agent in Sim? + +Sim lets teams build a procurement agent by connecting an approved trigger, model, business systems, deterministic conditions, and human-review steps in the visual workflow builder. + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. A procurement workflow in Sim can: + +1. Start with an approved form, webhook, email, schedule, or supported application trigger. +2. Normalize the request into a documented schema. +3. Retrieve only the policies and records required for the task. +4. Use a model to classify or summarize unstructured content. +5. Use deterministic conditions for thresholds, required fields, and routing. +6. Pause with Human in the Loop when an authorized person must review the result. +7. Check the reviewer's approve-or-reject field with a downstream Condition. +8. Write the approved result to the system of record and retain the execution evidence. + +In Sim, Human in the Loop pauses a run and resumes it with submitted form fields; approval is a field that a downstream Condition must evaluate. The Guardrails block reports passed or failed, so a downstream Condition is also required when the workflow must branch or stop based on that result. The Wait block resumes after a set time rather than after an external event. + +## How should procurement teams measure an AI agent pilot? + +Procurement teams should measure an AI agent pilot against a human-reviewed baseline for completion time, extraction accuracy, routing accuracy, exception quality, policy compliance, and reviewer effort. + +Useful pilot metrics include: + +- Percentage of requests completed without follow-up for missing information +- Accuracy of extracted supplier, amount, date, line-item, and clause fields +- Precision and recall for identifying policy exceptions +- Percentage of cases routed to the correct reviewer +- False approvals and false rejections, tracked separately +- Median reviewer time per case and end-to-end cycle time +- Percentage of outputs with traceable source evidence +- Tool-call and integration failure rate +- Percentage of cases requiring manual rework + +A pilot should begin with a narrow workflow and a representative evaluation set that includes ordinary requests, incomplete documents, conflicting evidence, adversarial instructions, and high-risk exceptions. The workflow should not receive broader permissions until it meets documented quality and control thresholds. [AI agent observability](https://www.sim.ai/library/ai-agent-observability) provides a framework for traces, metrics, and evaluations. + +## What is the best first AI agent use case for procurement? + +Procurement teams should usually start with intake triage or review-packet preparation because these use cases reduce administrative work without granting the agent authority to spend money or accept legal terms. + +A strong first pilot has frequent cases, accessible source data, an existing policy, measurable reviewer effort, and a clear escalation path. Fully autonomous purchasing, payment release, or contract acceptance is a poor first project because the cost of an incorrect action is high and the control requirements are substantially greater. ## 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. +Procurement AI agent adoption succeeds when teams address data, integration, change-management, and trust constraints before scaling. Adoption is rarely painless. The most significant hurdles are messy or siloed data, integration complexity across ERP and spend tools, change management, and trust in autonomous decisions. Data is often the biggest blocker: [GEP-supported research], reviewed as of October 2026,(https://www.gep.com/blogs/strategy/clean-data-agentic-ai-orchestration-key-to-procurement-transformation) found that more than half of organizations (53%) do not have their key procurement data integrated into a single system or architecture. As of October 2026, Icertis [reported similar friction](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/), with integration issues (88%) and data quality issues (75%) detracting from procurement confidence in AI. 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. + +## What controls do AI procurement agents need? + +AI procurement agents need least-privilege access, deterministic policy checks, human approvals, source-linked outputs, audit logs, and continuous evaluation before they can handle consequential work. + +1. **Scoped credentials:** Give each workflow access only to required systems, records, and actions. +2. **Separation of duties:** Do not let one agent request, approve, and execute the same purchase. +3. **Deterministic thresholds:** Implement spend limits, tolerance calculations, and mandatory-review rules in code. +4. **Human approval:** Pause before commitments, exceptions, payments, vendor activation, or contract acceptance. +5. **Evidence preservation:** Retain source document identifiers and the information used for each recommendation. +6. **Auditability:** Record model inputs, tool calls, outputs, workflow versions, reviewer decisions, and final actions. +7. **Data controls:** Limit sensitive data exposure and apply the organization's retention and residency requirements. +8. **Evaluation:** Test extraction accuracy, routing, policy adherence, tool selection, and refusal behavior with representative cases. +9. **Failure handling:** Define what happens when a system is unavailable, a document is unreadable, or evidence conflicts. +10. **Change management:** Re-test workflows when policies, prompts, models, integrations, or approval matrices change. + +These controls preserve accountability while agents handle collection, classification, comparison, and recommendation. + ## 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. +Procurement teams should begin with a narrow, measurable agent that preserves human authority over spending, risk, and contractual commitments. Start gradually and ship one narrow agent this quarter – the teams pulling ahead are the ones learning from a live use case rather than taking an over-theoretical approach. Pick a repeatable task like supplier email triage, wire in your real systems and approvals, and measure the time it saves. You can [build that first agent in Sim](https://www.sim.ai) from a template today, then expand once the results are on the table. From e8e946c9188b1be5f06f0a7f2f4dbf61002b47e7 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 17:15:06 -0700 Subject: [PATCH 25/68] docs(library): update sim-open-source-zapier-alternative (#8646) Co-authored-by: Sim Pi Agent --- .../index.mdx | 148 ++++++++++++++++-- 1 file changed, 138 insertions(+), 10 deletions(-) diff --git a/apps/sim/content/library/sim-open-source-zapier-alternative/index.mdx b/apps/sim/content/library/sim-open-source-zapier-alternative/index.mdx index 53f4a0cb0c3..4938d7104b6 100644 --- a/apps/sim/content/library/sim-open-source-zapier-alternative/index.mdx +++ b/apps/sim/content/library/sim-open-source-zapier-alternative/index.mdx @@ -3,37 +3,87 @@ slug: sim-open-source-zapier-alternative title: 'Sim vs Zapier: Open-Source AI Agents vs Zaps, Compared' description: 'Sim vs Zapier, head to head: an Apache 2.0-core, self-hostable, BYOK AI workspace versus proprietary cloud Zaps and Zapier Agents across licensing, building, agent depth, deployment, and pricing.' date: 2026-09-01 -updated: 2026-10-01 +updated: 2026-10-06 authors: - andrew -readingTime: 9 +readingTime: 18 tags: [Automation, Open Source, AI Agents, Sim] ogImage: /library/sim-open-source-zapier-alternative/cover.jpg draft: false faq: - q: "Is Sim an open-source alternative to Zapier?" - a: "Yes. Sim is an open-source alternative to Zapier whose core is licensed under Apache 2.0. Enterprise features in apps/sim/ee use the separate Sim Enterprise License, which requires an Enterprise subscription for production use. Sim combines AI agents and deterministic workflow logic in one workspace. You can inspect the code, self-host the platform, and use your own API keys." + a: "Sim is an open-source alternative to Zapier whose core is licensed under Apache 2.0. Enterprise features in apps/sim/ee use the separate Sim Enterprise License, which requires an Enterprise subscription for production use. Sim combines AI agents and deterministic workflow logic in one workspace. You can inspect the code, self-host the platform, and use your own API keys." - q: "Can I self-host Sim?" a: "Sim supports self-hosting through Docker, Kubernetes, or npx sim-setup. Zapier operates as a proprietary cloud service without customer-managed hosting. Self-hosting gives you more control over deployment and data handling." - q: "Does Sim support BYOK?" - a: "BYOK lets you connect Sim to supported model providers with your own API keys. BYOK differs from local-model execution and regular hosted model access. Your own keys give you direct control over provider accounts and usage." + a: "Sim supports BYOK so you can connect supported model providers with your own API keys. BYOK differs from local-model execution and regular hosted model access. Your own keys give you direct control over provider accounts and usage." - q: "How does Sim’s pricing compare to Zapier’s?" a: "Sim pricing combines per-user plans with credits deducted at one credit per $0.005 of metered usage. Zapier pricing meters Zap tasks, while Zapier Agents meters activities separately. Compare expected workflow tasks and agent activity rather than treating those units as equivalent." - q: "Can I migrate Zaps to Sim?" - a: "You can recreate compatible Zap workflows in Sim by mapping each trigger and action to available blocks and integrations. Each Zap must map to Sim’s graph model and available integrations. A staged migration lets you test outputs before replacing an active Zap." + a: "Sim lets you recreate compatible Zap workflows by mapping each trigger and action to available blocks and integrations. Each Zap must map to Sim’s graph model and available integrations. A staged migration lets you test outputs before replacing an active Zap." - q: "Does Sim have as many integrations as Zapier?" a: "Zapier has a larger app integration catalog than Sim. Sim focuses on agent-native workflows with API and SDK access. Choose Zapier when connector coverage determines feasibility, and choose Sim when control and agent depth carry more weight." + - q: "What is the best open-source Zapier alternative?" + a: "Sim is the best open-source Zapier alternative for teams that need an Apache 2.0 core, self-hosting, local-model connectivity, and a visual workspace for building AI agents." + - q: "Is Sim an open-source Zapier replacement?" + a: "Sim is an open-source-core alternative to Zapier for AI-agent workflows, but Sim is not a one-for-one replacement for every Zapier connector or deterministic Zap." + - q: "Is Zapier open source?" + a: "Zapier is proprietary software and is not distributed under an OSI-approved open-source license." + - q: "Can Zapier be self-hosted?" + a: "Zapier does not offer a generally available self-hosted edition in its published product offering as of October 2026." + - q: "Can Sim be self-hosted?" + a: "Sim can be self-hosted, and any self-hosted Sim deployment can connect to supported local-model servers without requiring Enterprise." + - q: "Is Sim free?" + a: "Sim's core can be used and self-hosted under the Apache License 2.0, but infrastructure, model usage, Sim Cloud plans, and production use of enterprise-only features can create costs." + - q: "Does Sim use the Apache 2.0 license?" + a: "Sim's core uses the Apache License 2.0, while code in apps/sim/ee uses the separate Sim Enterprise License." + - q: "Does Sim require Enterprise for local models?" + a: "Sim does not require Enterprise for local models because Ollama, vLLM, LM Studio, and LiteLLM-compatible endpoints work with any self-hosted Sim deployment." + - q: "Does Sim support bring your own key?" + a: "Sim supports workspace BYOK on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise as of October 2026." + - q: "Does Zapier support AI agents?" + a: "Zapier supports AI agents through Zapier Agents as part of its proprietary managed cloud product suite." + - q: "Is Sim better than Zapier for AI agents?" + a: "Sim is better than Zapier for AI-agent teams that prioritize open-source core code, self-hosting, local models, and infrastructure control." + - q: "Is Zapier better than Sim for app automation?" + a: "Zapier is better than Sim when a team prioritizes Zapier's managed cloud experience and proprietary app connector ecosystem over source access and self-hosting." + - q: "What is the difference between Sim and Zapier?" + a: "Sim is the open-source AI workspace for building, deploying, and managing agents, while Zapier is a proprietary cloud service centered on app automation and related AI products." + - q: "What is the difference between Sim and n8n?" + a: "Sim has an Apache 2.0 open-source core and centers on AI agents, while n8n centers on workflow automation under the source-available Sustainable Use License." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license." + - q: "Is Sim a better n8n alternative than Zapier?" + a: "Sim is a better n8n alternative than Zapier when the buyer requires an OSI-approved open-source core and an AI-agent workspace, while Zapier is better suited to buyers seeking proprietary managed cloud automation." + - q: "Can I migrate from Zapier to Sim?" + a: "Sim can replace selected Zapier workflows by rebuilding their triggers, actions, mappings, conditions, credentials, and failure paths as tested Sim workflows." + - q: "Does Sim automatically import Zaps?" + a: "Sim should be treated as a workflow reconstruction target rather than as an automatic one-click importer for Zaps." + - q: "Should I migrate every Zap to Sim?" + a: "Sim should replace the Zaps that benefit from AI reasoning, self-hosting, source access, model flexibility, or multi-step agent behavior rather than every simple automation by default." + - q: "What should I test when migrating from Zapier to Sim?" + a: "Sim migrations should test representative inputs, missing fields, duplicate events, credentials, permissions, provider failures, model variability, and downstream side effects before deployment." + - q: "Where can I compare more Zapier alternatives?" + a: "Sim's guide titled 8 Best Zapier Alternatives in 2026, Compared covers a wider range of products for buyers who are not specifically focused on open-source AI agents." --- ## TL;DR +Sim is the best open-source Zapier alternative for teams that want to build AI agents in a visual workspace, inspect and modify the core code, and run the system on their own infrastructure. Zapier remains a strong choice for proprietary, cloud-based trigger-and-action automation across its app ecosystem. + This is a head-to-head comparison of Sim and Zapier. If you are still building a shortlist across several vendors, start with the [best Zapier alternatives](https://www.sim.ai/library/best-zapier-alternatives), which compares Sim, Make, n8n, Pipedream, Workato, and others by use case. -- Sim is a self-hostable, bring-your-own-key alternative to Zapier with an Apache 2.0-licensed core. [Zapier provides proprietary cloud automation](https://zapier.com/blog/cloud-vs-self-hosting/), while Sim provides an open-source, agent-native workspace. -- Sim combines natural-language building, a visual block canvas, and API or SDK access. Zapier centers on [trigger-action Zaps](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap) and [offers Agents separately](https://zapier.com/agents). +- Sim is a self-hostable, bring-your-own-key alternative to Zapier with an Apache 2.0-licensed core. [Zapier provides proprietary cloud automation](https://zapier.com/blog/cloud-vs-self-hosting/), while Sim provides an agent-native workspace around its open-source core. +- Sim combines natural-language building, a visual builder, and API or SDK access. Zapier centers on [trigger-action Zaps](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap) and [offers Agents separately](https://zapier.com/agents). - Sim places AI reasoning and deterministic functions, conditions, routers, and loops in one graph. [Zapier adds AI capabilities to an automation-first product](https://zapier.com/blog/zapier-ai-guide/). - Sim charges per user plus credit-based usage. Zapier meters [Zap tasks](https://zapier.com/pricing) and [Agent activities](https://help.zapier.com/hc/en-us/articles/26559132765325-How-is-Zapier-Agents-usage-measured) separately, so both meters can contribute to costs. +## What is the best open-source Zapier alternative? + +Sim is the best open-source Zapier alternative for teams whose primary goal is building self-hosted AI agents rather than reproducing every conventional Zapier automation. Sim’s core can be inspected, modified, and self-hosted under the Apache License 2.0, while its visual builder combines model calls, tools, integrations, API requests, conditions, and human input in multi-step agent workflows. + +Sim is not a one-for-one Zapier clone. [Zapier is optimized for cloud-hosted automation built around triggers and actions](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap), while Sim is optimized for agents that reason with models, call tools, and automate real work across multiple steps. Choose Sim when open-source core code, self-hosting, model choice, and agent orchestration are primary requirements; choose Zapier when a managed cloud service and its proprietary connector catalog matter more than source access or infrastructure control. + ## Zapier vs. Sim: what kind of product each one is Zapier is a proprietary cloud automation platform built around [Zaps](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap). Each Zap connects a trigger to one or more actions, which makes the product approachable for no-code automation work. [Zapier Agents](https://zapier.com/agents) adds AI agents as a separate product alongside the core Zap builder. @@ -42,6 +92,14 @@ Sim is our [open-source AI workspace](https://github.com/simstudioai/sim) for wo Zapier fits users who want familiar no-code automation and [broad access to packaged app connectors](https://zapier.com/apps). Sim fits technical operations, RevOps, and growth users who need to build agent-native systems with more control over logic, models, and deployment. The products overlap in workflow automation, but their starting points differ. Zapier starts with trigger-and-action automation, while Sim starts with AI agents operating inside structured workflows. +### Can Zapier be self-hosted? + +Zapier cannot be deployed as a generally available self-hosted edition under its published product offering as of October 2026. [Zapier operates its products as managed cloud services](https://zapier.com/blog/cloud-vs-self-hosting/), reducing infrastructure work for customers without providing an installable, customer-operated Zapier edition. + +### Does Zapier support AI agents? + +Zapier supports AI-agent use cases through [Zapier Agents](https://zapier.com/agents), while Sim makes AI-agent creation the center of its open-source workspace. Zapier fits buyers who want agents as part of a managed automation suite; Sim is the stronger fit when an agent requires open-source core code, self-hosted execution, local-model connectivity, or deeper control over a multi-step workflow. + ## License, hosting, and who controls the data Sim provides an open source Zapier alternative through an Apache 2.0 core license, customer-operated hosting, and bring-your-own-key model access. The [public repository and license](https://github.com/simstudioai/sim) let you inspect, modify, and deploy the core software under the license terms. Features in `apps/sim/ee`, such as SSO, SCIM, access control, audit logs, and white-labeling, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. Zapier remains proprietary software delivered through Zapier’s cloud, without a customer-operated self-hosting option; Zapier's own discussion of [cloud versus self-hosting](https://zapier.com/blog/cloud-vs-self-hosting/) describes requests to run the platform entirely on customer infrastructure as a self-hosting scenario rather than an available deployment path. @@ -52,13 +110,31 @@ BYOK lets you connect supported model providers with your own API credentials. B [Zapier’s managed cloud](https://zapier.com/blog/cloud-vs-self-hosting/) reduces infrastructure work, which may suit buyers who prefer vendor-operated software. Sim gives you more control, but you must operate, secure, monitor, and update the deployment. Buyers searching for an open-source Zapier alternative should weigh Sim’s added deployment control against the work required to operate it. You can also compare other [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). +### What are the key facts about Sim, Zapier, and n8n? + +Sim, Zapier, and n8n have materially different licenses, deployment models, and product centers as of October 2026. + +- Sim’s core uses the OSI-approved Apache License 2.0, supports self-hosting, and centers on building and operating AI agents; enterprise-only code has a separate license. +- [Zapier is a proprietary managed cloud service](https://zapier.com/legal) that centers on app automation and also offers Zapier Agents. +- n8n supports self-hosting and source access, but its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved open source. + +These entities demonstrate why source visibility, OSI-approved licensing, and self-hosting are separate questions: a product can expose its source without granting the rights associated with an OSI-approved open-source license. + +### Is Sim actually open source? + +Sim’s core is open source under the [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), but Sim’s enterprise-only directory is governed by a separate license. Code in `apps/sim/ee` uses the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE): it is free for development, testing, and internal non-production use, while production use requires an active Sim Enterprise subscription and modification and redistribution are not permitted. The precise description as of October 2026 is that Sim’s core is Apache 2.0—not that every component in the repository has the same license. + +### Can Sim be self-hosted? + +Sim can be self-hosted on infrastructure controlled by the team operating it. Sim documents supported deployment paths in its [self-hosting guide](https://docs.sim.ai/platform/self-hosting) and [Docker guide](https://docs.sim.ai/platform/self-hosting/docker). Any self-hosted Sim deployment can connect to Ollama through `OLLAMA_URL` and to vLLM, LM Studio, or LiteLLM-compatible endpoints through the documented base URL configuration; as of October 2026, local-model connectivity is a self-hosting capability and does not require Sim Enterprise. + ## Building workflows: Chat and blocks vs. Zaps and Agents -Sim lets you build one workflow through three interfaces while preserving the same underlying graph. In Chat, you describe the workflow in plain language and ask Sim to create or modify blocks. The visual canvas lets you inspect each connection and edit the blocks directly. For programmatic work, the API and SDK let you create or manage workflows through code. These interfaces are documented in the [Sim introduction](https://docs.sim.ai/introduction). +Sim lets you build one workflow through three interfaces while preserving the same underlying graph. In Chat, you describe the workflow in plain language and ask Sim to create or modify blocks. The visual builder lets you inspect each connection and edit the blocks directly. For programmatic work, the API and SDK let you create or manage workflows through code. These interfaces are documented in the [Sim introduction](https://docs.sim.ai/introduction). Zapier centers workflow building on [Zaps, which connect a trigger to one or more actions](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap). That model gives no-code users a familiar way to automate predictable sequences, such as adding a new form submission to a CRM and sending a notification. [Zapier Agents](https://zapier.com/agents) uses a separate interface and mental model for work that requires an AI agent to choose tools or decide what to do next. -Sim and Zapier organize larger AI workflows differently. In Sim, you can start with a prompt in Chat, refine the generated graph on the canvas, and access the same workflow through code. Reasoning blocks and regular automation blocks remain visible in one design. +Sim and Zapier organize larger AI workflows differently. In Sim, you can start with a prompt in Chat, refine the generated graph in the visual builder, and access the same workflow through code. Reasoning blocks and regular automation blocks remain visible in one design. With Zapier, you may need to decide whether each part belongs in a Zap, an Agent, a Chatbot, or Copilot. [Zapier describes these as products and features in its AI lineup](https://zapier.com/blog/zapier-ai-guide/), and you manage their behavior and handoffs through their respective surfaces. Sim suits technical operators who want natural language generation, visual inspection, and code access to the same agent workflow. @@ -90,7 +166,7 @@ Sim’s deployment model suits workflows that need several entry points. For exa ## Pricing and metering at scale -Sim combines seat pricing with credit-based usage. [Sim’s Pro plan costs $25 per user each month](https://www.sim.ai/pricing). Sim deducts one credit for each $0.005 of metered usage, so your bill reflects both seat count and workflow consumption. +Sim combines seat pricing with credit-based usage as of October 2026. [Sim’s Pro plan costs $25 per user each month](https://www.sim.ai/pricing). Sim deducts one credit for each $0.005 of metered usage, so your bill reflects both seat count and workflow consumption. Zapier meters its automation and agent products separately. [Zaps consume tasks when successful actions run](https://zapier.com/pricing), while [Zapier Agents usage is measured in activities](https://help.zapier.com/hc/en-us/articles/26559132765325-How-is-Zapier-Agents-usage-measured). A business process that uses an Agent for decisions and Zaps for downstream app actions can consume both allowances. @@ -98,11 +174,53 @@ The units do not support a direct one-to-one comparison. A Zapier task, a Zapier ## Comparison table +Sim and Zapier overlap in visual workflow creation, but Sim centers on open-source AI-agent development while Zapier centers on proprietary cloud automation. This comparison uses first-party product, licensing, and pricing sources as of October 2026. + +| Criterion | Sim | Zapier | +| --- | --- | --- | +| License | Sim’s core is available under the [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), an OSI-approved open-source license. Code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); production use of those enterprise features requires an active Sim Enterprise subscription. | Zapier is a proprietary cloud service governed by [Zapier’s legal terms](https://zapier.com/legal), not software distributed under an OSI-approved open-source license. | +| Self-hosting | Sim supports [self-hosted deployment](https://docs.sim.ai/platform/self-hosting), including deployments that connect to local model servers. | [Zapier operates as a managed cloud service](https://zapier.com/blog/cloud-vs-self-hosting/) and does not offer a generally available self-hosted edition in its published product plans. | +| AI agents | Sim is the open-source AI workspace where teams build, deploy, and manage agents with models, tools, branching, and multi-step execution. | Zapier offers [Zapier Agents](https://zapier.com/agents) alongside its established trigger-and-action automation products. | +| Pricing | Sim supports cloud use and self-hosting. Workspace BYOK is available on any Sim Cloud plan, while self-hosted teams pay their own infrastructure and model-provider costs; current cloud details are on [Sim’s pricing page](https://www.sim.ai/pricing). | Zapier publishes plan details and included usage on [its pricing page](https://zapier.com/pricing); customers pay for managed cloud products rather than deploying Zapier on their own infrastructure. | +| Integrations | Sim connects models, tools, APIs, and application actions inside agent workflows, with extensibility through its open-source core. | Zapier provides a proprietary catalog of app connectors in the [Zapier app directory](https://zapier.com/apps). | +| Local models | Any self-hosted Sim deployment can connect to Ollama, vLLM, LM Studio, or LiteLLM-compatible endpoints as described in the [Docker guide](https://docs.sim.ai/platform/self-hosting/docker). | Zapier does not provide a self-hosted deployment for connecting the platform directly to a local model server inside a customer-operated Zapier instance. | +| Best fit | Sim is best for teams building AI agents that require source access, deployment control, model choice, or self-hosting. | Zapier is best for teams prioritizing managed SaaS automation and its established connector ecosystem. | + +The product-model view below shows how those differences carry into builders, agent depth, context, deployment, and metering. + | Product | License and hosting | Builder model | Agent depth | Native context | Deployment surfaces | Pricing model | | --- | --- | --- | --- | --- | --- | --- | | Sim | Apache 2.0 core; enterprise features separately licensed. Self-hosted or managed. BYOK. | Chat, visual blocks, and API or SDK. | Agent reasoning and deterministic logic share one graph. | Tables, Files, and Knowledge Bases sit inside the workspace. | One workflow can run as an API, hosted chat, or MCP server. | Per-user plans plus [credit-based usage](https://www.sim.ai/pricing). | | Zapier | [Proprietary cloud service without customer-operated self-hosting](https://zapier.com/blog/cloud-vs-self-hosting/). | [Trigger-action Zaps](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap) with [Agents](https://zapier.com/agents) offered separately. | [AI steps extend an automation-first product](https://zapier.com/blog/zapier-ai-guide/). | [Tables](https://help.zapier.com/hc/en-us/articles/9804340895245-Create-tables-and-store-data-with-Zapier-Tables), [Chatbots](https://zapier.com/ai/chatbot), and Copilot operate as separate products. | Zaps, [Agents](https://zapier.com/agents), and [Chatbots](https://help.zapier.com/hc/en-us/articles/21958023866381-Share-and-embed-a-chatbot) cover separate deployment surfaces. | [Task-based Zaps](https://zapier.com/pricing) and [activity-based Agents](https://help.zapier.com/hc/en-us/articles/26559132765325-How-is-Zapier-Agents-usage-measured). | +## How do you migrate from Zapier to Sim? + +Sim supports a deliberate Zapier migration in which each Zap’s trigger, actions, data mappings, branches, and failure paths are reconstructed as an agent workflow. Start with workflows that benefit from model reasoning, self-hosting, source access, or multi-step agent behavior rather than migrating every simple Zap at once. + +### How do you inventory the Zaps you want to migrate? + +Sim migrations should begin with an inventory of each Zap’s business purpose, trigger, actions, connected accounts, inputs, outputs, branching rules, schedules, and failure behavior. Rank that inventory by business impact and migration value so document analysis, support triage, research, enrichment, or approval-heavy work can move before simple deterministic notifications. + +### How do you map Zapier triggers and actions into Sim? + +Sim workflows should map the event that starts a Zap to an appropriate trigger and each Zapier action to a corresponding tool, integration, or API operation. Document required fields, transformations, authentication methods, expected outputs, timeout behavior, and the destination for failed runs before rebuilding the workflow. + +### How do you add AI reasoning without making the workflow unreliable? + +Sim workflows should use model reasoning only where interpretation, classification, extraction, generation, or tool selection adds value. Keep deterministic operations deterministic, use explicit conditions for business rules, constrain model inputs and outputs, and route low-confidence or high-impact cases to human review. + +### How do you connect model providers or local models? + +Sim lets teams use workspace BYOK on any Sim Cloud plan or connect supported local-model servers from a self-hosted deployment. As of October 2026, [organization-level model keys require Pro for Teams, Max for Teams, or Enterprise, while workspace BYOK works on any Sim Cloud plan](https://docs.sim.ai/platform/costs#bring-your-own-key-byok); self-hosted Sim can connect to Ollama, vLLM, LM Studio, or LiteLLM-compatible endpoints without requiring Enterprise. + +### How do you test a migrated workflow? + +Sim migrations should test representative inputs, missing data, duplicate events, provider errors, model variability, permissions, and downstream failures before deployment. Compare outputs with the existing Zap over a controlled test set and, for high-impact automations, run both paths in parallel without duplicating side effects until the Sim workflow behaves as expected. + +### How do you deploy the migrated workflow safely? + +Sim workflows should be deployed incrementally with clear ownership, monitoring, rollback procedures, and limits on external side effects. Move one workflow at a time, confirm credentials and permissions, observe real runs, and retire the old Zap only after the replacement passes the team’s acceptance criteria. + ## Where Zapier is still the better choice [Zapier’s larger app catalog](https://zapier.com/apps) gives it an advantage when connector coverage determines whether an automation can ship. If your workflow depends on a niche SaaS product that Sim does not support directly, Zapier may avoid custom API work. Connector breadth can outweigh Sim’s agent-native model for conventional trigger-action workflows. @@ -111,6 +229,16 @@ Zapier also suits companies with employees who already know how to build and mai [Zapier’s higher tiers include enterprise administration and governance features](https://zapier.com/pricing) for companies with formal governance requirements. Buyers that need mature user management, centralized controls, and a vendor already known to procurement may prefer Zapier. Sim offers more control through self-hosting, but your company must operate that deployment and support its users. +## How do Sim, Zapier, and n8n differ? + +Sim, Zapier, and n8n differ most clearly in product focus and licensing: Sim centers on AI agents with an Apache 2.0 core, Zapier provides proprietary cloud automation, and n8n provides source-available workflow automation under its Sustainable Use License. + +n8n is an important incumbent for self-hosted automation because it provides source access and a self-hosting path. However, n8n’s [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved open source and restricts some commercial uses, including offering n8n to customers as a hosted service. Sim is the clearer choice when an OSI-approved core license and AI-agent workspace are central requirements; n8n can fit teams that want its node-based automation model and accept its source-available license; Zapier can fit teams that prefer a proprietary managed cloud service. + +## What other Zapier alternatives should you compare? + +Sim should be compared with different products depending on whether the buyer prioritizes open-source licensing, self-hosting, AI agents, or conventional SaaS automation. Use the broader-market comparison introduced near the top of this guide, the [best self-hosted AI workflow automation platforms](https://www.sim.ai/library/best-self-hosted-ai-workflow-automation-platforms-2026) for infrastructure control, and the open-source agent-platform comparison linked in the licensing section for source and license questions. + ## Choose Zapier when / choose Sim when Choose Zapier when the following conditions apply. From d4f26a71b367be4ca52019c4bb889b0d2689b2ca Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Oct 2026 17:46:49 -0700 Subject: [PATCH 26/68] fix(seo): make content pagination indexable (#8651) --- .claude/rules/landing-seo-geo.md | 1 + .cursor/rules/landing-seo-geo.mdc | 1 + apps/sim/app/(landing)/blog/page.tsx | 15 +++++++-------- .../content-index-page/content-index-page.tsx | 4 ++-- apps/sim/app/(landing)/library/page.tsx | 15 +++++++-------- apps/sim/lib/content/index-list.ts | 11 +++++++++++ apps/sim/lib/content/seo.ts | 16 ++++++---------- apps/sim/lib/landing/seo.ts | 9 ++------- 8 files changed, 37 insertions(+), 35 deletions(-) diff --git a/.claude/rules/landing-seo-geo.md b/.claude/rules/landing-seo-geo.md index d603c2723e7..fbfbff1a9fc 100644 --- a/.claude/rules/landing-seo-geo.md +++ b/.claude/rules/landing-seo-geo.md @@ -17,6 +17,7 @@ paths: - All copy is server-rendered text: no text baked into images, no content that exists only after a client effect runs. - Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `` has `priority` (LCP element). The navbar `