Compare commits

..

28 Commits

Author SHA1 Message Date
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Adam Gough
13981549d1 fix(grain): grain trigger update (#2739)
* grain trigger new requirements

* removed comment

* made it generic for all triggers

* fire only for specific trigger type

* removed comments
2026-01-08 23:10:11 -08:00
Waleed
554dcdf062 improvement(execution-snapshot): enhance workflow preview in logs and deploy modal (#2742)
* added larger live deployment preview

* edited subblock UI

* removed comments

* removed carrot

* updated styling to match existing subblocks

* enriched workflow preview

* fix connetion in log preview

* cleanup

* ack PR comments

* more PR comments

* more

* cleanup

* use reactquery cache in deploy modal

* ack comments

* ack PR comment

---------

Co-authored-by: aadamgough <adam@sim.ai>
2026-01-08 23:04:54 -08:00
Adam Gough
6b28742b68 fix(linear): missing params (#2740)
* added missing params

* fixed linear bugs
2026-01-08 20:42:09 -08:00
Vikhyath Mondreti
e5c95093f6 improvement(autoconnect): click to add paths also autoconnect (#2737) 2026-01-08 18:16:15 -08:00
Waleed
b87af80bff feat(i18n): update translations (#2732)
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
2026-01-08 18:14:00 -08:00
Vikhyath Mondreti
c2180bf8a0 improvement(enterprise): feature flagging + runtime checks consolidation (#2730)
* improvement(enterprise): enterprise checks code consolidation

* update docs

* revert isHosted check

* add unique index to prevent multiple orgs per user

* address greptile comments

* ui bug
2026-01-08 13:53:22 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
109 changed files with 12166 additions and 4673 deletions

View File

@@ -0,0 +1,76 @@
---
title: Enterprise
description: Enterprise-Funktionen für Organisationen mit erweiterten
Sicherheits- und Compliance-Anforderungen
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen.
---
## Bring Your Own Key (BYOK)
Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim Studio.
### Unterstützte Anbieter
| Anbieter | Verwendung |
|----------|-------|
| OpenAI | Knowledge Base-Embeddings, Agent-Block |
| Anthropic | Agent-Block |
| Google | Agent-Block |
| Mistral | Knowledge Base OCR |
### Einrichtung
1. Navigieren Sie zu **Einstellungen** → **BYOK** in Ihrem Workspace
2. Klicken Sie auf **Schlüssel hinzufügen** für Ihren Anbieter
3. Geben Sie Ihren API-Schlüssel ein und speichern Sie
<Callout type="warn">
BYOK-Schlüssel werden verschlüsselt gespeichert. Nur Organisationsadministratoren und -inhaber können Schlüssel verwalten.
</Callout>
Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim Studio. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück.
---
## Single Sign-On (SSO)
Enterprise-Authentifizierung mit SAML 2.0- und OIDC-Unterstützung für zentralisiertes Identitätsmanagement.
### Unterstützte Anbieter
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Jeder SAML 2.0- oder OIDC-Anbieter
### Einrichtung
1. Navigieren Sie zu **Einstellungen** → **SSO** in Ihrem Workspace
2. Wählen Sie Ihren Identitätsanbieter
3. Konfigurieren Sie die Verbindung mithilfe der Metadaten Ihres IdP
4. Aktivieren Sie SSO für Ihre Organisation
<Callout type="info">
Sobald SSO aktiviert ist, authentifizieren sich Teammitglieder über Ihren Identitätsanbieter anstelle von E-Mail/Passwort.
</Callout>
---
## Self-Hosted
Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgebungsvariablen aktiviert werden:
| Variable | Beschreibung |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger |
<Callout type="warn">
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
</Callout>

View File

@@ -0,0 +1,75 @@
---
title: Enterprise
description: Enterprise features for organizations with advanced security and compliance requirements
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
---
## Bring Your Own Key (BYOK)
Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
### Supported Providers
| Provider | Usage |
|----------|-------|
| OpenAI | Knowledge Base embeddings, Agent block |
| Anthropic | Agent block |
| Google | Agent block |
| Mistral | Knowledge Base OCR |
### Setup
1. Navigate to **Settings** → **BYOK** in your workspace
2. Click **Add Key** for your provider
3. Enter your API key and save
<Callout type="warn">
BYOK keys are encrypted at rest. Only organization admins and owners can manage keys.
</Callout>
When configured, workflows use your key instead of Sim Studio's hosted keys. If removed, workflows automatically fall back to hosted keys.
---
## Single Sign-On (SSO)
Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management.
### Supported Providers
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Any SAML 2.0 or OIDC provider
### Setup
1. Navigate to **Settings** → **SSO** in your workspace
2. Choose your identity provider
3. Configure the connection using your IdP's metadata
4. Enable SSO for your organization
<Callout type="info">
Once SSO is enabled, team members authenticate through your identity provider instead of email/password.
</Callout>
---
## Self-Hosted
For self-hosted deployments, enterprise features can be enabled via environment variables:
| Variable | Description |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
<Callout type="warn">
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
</Callout>

View File

@@ -15,6 +15,7 @@
"permissions",
"sdks",
"self-hosting",
"./enterprise/index",
"./keyboard-shortcuts/index"
],
"defaultOpen": false

View File

@@ -0,0 +1,76 @@
---
title: Enterprise
description: Funciones enterprise para organizaciones con requisitos avanzados
de seguridad y cumplimiento
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión.
---
## Bring Your Own Key (BYOK)
Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim Studio.
### Proveedores compatibles
| Proveedor | Uso |
|----------|-------|
| OpenAI | Embeddings de base de conocimiento, bloque Agent |
| Anthropic | Bloque Agent |
| Google | Bloque Agent |
| Mistral | OCR de base de conocimiento |
### Configuración
1. Navega a **Configuración** → **BYOK** en tu espacio de trabajo
2. Haz clic en **Añadir clave** para tu proveedor
3. Introduce tu clave API y guarda
<Callout type="warn">
Las claves BYOK están cifradas en reposo. Solo los administradores y propietarios de la organización pueden gestionar las claves.
</Callout>
Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim Studio. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas.
---
## Single Sign-On (SSO)
Autenticación enterprise con soporte SAML 2.0 y OIDC para gestión centralizada de identidades.
### Proveedores compatibles
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Cualquier proveedor SAML 2.0 u OIDC
### Configuración
1. Navega a **Configuración** → **SSO** en tu espacio de trabajo
2. Elige tu proveedor de identidad
3. Configura la conexión usando los metadatos de tu IdP
4. Activa SSO para tu organización
<Callout type="info">
Una vez que SSO está activado, los miembros del equipo se autentican a través de tu proveedor de identidad en lugar de correo electrónico/contraseña.
</Callout>
---
## Self-Hosted
Para implementaciones self-hosted, las funciones enterprise se pueden activar mediante variables de entorno:
| Variable | Descripción |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Inicio de sesión único con SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Grupos de sondeo para activadores de correo electrónico |
<Callout type="warn">
BYOK solo está disponible en Sim Studio alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno.
</Callout>

View File

@@ -0,0 +1,76 @@
---
title: Entreprise
description: Fonctionnalités entreprise pour les organisations ayant des
exigences avancées en matière de sécurité et de conformité
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Entreprise fournit des fonctionnalités avancées pour les organisations ayant des exigences renforcées en matière de sécurité, de conformité et de gestion.
---
## Apportez votre propre clé (BYOK)
Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim Studio.
### Fournisseurs pris en charge
| Fournisseur | Utilisation |
|----------|-------|
| OpenAI | Embeddings de base de connaissances, bloc Agent |
| Anthropic | Bloc Agent |
| Google | Bloc Agent |
| Mistral | OCR de base de connaissances |
### Configuration
1. Accédez à **Paramètres** → **BYOK** dans votre espace de travail
2. Cliquez sur **Ajouter une clé** pour votre fournisseur
3. Saisissez votre clé API et enregistrez
<Callout type="warn">
Les clés BYOK sont chiffrées au repos. Seuls les administrateurs et propriétaires de l'organisation peuvent gérer les clés.
</Callout>
Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim Studio. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées.
---
## Authentification unique (SSO)
Authentification entreprise avec prise en charge de SAML 2.0 et OIDC pour une gestion centralisée des identités.
### Fournisseurs pris en charge
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Tout fournisseur SAML 2.0 ou OIDC
### Configuration
1. Accédez à **Paramètres** → **SSO** dans votre espace de travail
2. Choisissez votre fournisseur d'identité
3. Configurez la connexion en utilisant les métadonnées de votre IdP
4. Activez le SSO pour votre organisation
<Callout type="info">
Une fois le SSO activé, les membres de l'équipe s'authentifient via votre fournisseur d'identité au lieu d'utiliser un email/mot de passe.
</Callout>
---
## Auto-hébergé
Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent être activées via des variables d'environnement :
| Variable | Description |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail |
<Callout type="warn">
BYOK est uniquement disponible sur Sim Studio hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement.
</Callout>

View File

@@ -0,0 +1,75 @@
---
title: エンタープライズ
description: 高度なセキュリティとコンプライアンス要件を持つ組織向けのエンタープライズ機能
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
---
## Bring Your Own Key (BYOK)
Sim Studioのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
### 対応プロバイダー
| プロバイダー | 用途 |
|----------|-------|
| OpenAI | ナレッジベースの埋め込み、エージェントブロック |
| Anthropic | エージェントブロック |
| Google | エージェントブロック |
| Mistral | ナレッジベースOCR |
### セットアップ
1. ワークスペースの**設定** → **BYOK**に移動します
2. プロバイダーの**キーを追加**をクリックします
3. APIキーを入力して保存します
<Callout type="warn">
BYOKキーは保存時に暗号化されます。組織の管理者とオーナーのみがキーを管理できます。
</Callout>
設定すると、ワークフローはSim Studioのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
---
## シングルサインオン (SSO)
集中型IDマネジメントのためのSAML 2.0およびOIDCサポートを備えたエンタープライズ認証。
### 対応プロバイダー
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- SAML 2.0またはOIDCに対応する任意のプロバイダー
### セットアップ
1. ワークスペースの**設定** → **SSO**に移動します
2. IDプロバイダーを選択します
3. IdPのメタデータを使用して接続を設定します
4. 組織のSSOを有効にします
<Callout type="info">
SSOを有効にすると、チームメンバーはメール/パスワードの代わりにIDプロバイダーを通じて認証します。
</Callout>
---
## セルフホスト
セルフホストデプロイメントの場合、エンタープライズ機能は環境変数を介して有効にできます:
| 変数 | 説明 |
|----------|-------------|
| `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン |
| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ |
<Callout type="warn">
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
</Callout>

View File

@@ -0,0 +1,75 @@
---
title: 企业版
description: 为具有高级安全性和合规性需求的组织提供企业级功能
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
---
## 自带密钥BYOK
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim Studio 托管的密钥。
### 支持的服务商
| Provider | Usage |
|----------|-------|
| OpenAI | 知识库嵌入、Agent 模块 |
| Anthropic | Agent 模块 |
| Google | Agent 模块 |
| Mistral | 知识库 OCR |
### 配置方法
1. 在您的工作区进入 **设置** → **BYOK**
2. 为您的服务商点击 **添加密钥**
3. 输入您的 API 密钥并保存
<Callout type="warn">
BYOK 密钥静态加密存储。仅组织管理员和所有者可管理密钥。
</Callout>
配置后,工作流将使用您的密钥而非 Sim Studio 托管密钥。如移除,工作流会自动切换回托管密钥。
---
## 单点登录SSO
企业级身份认证,支持 SAML 2.0 和 OIDC实现集中式身份管理。
### 支持的服务商
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- 任何 SAML 2.0 或 OIDC 服务商
### 配置方法
1. 在您的工作区进入 **设置** → **SSO**
2. 选择您的身份提供商
3. 使用 IdP 元数据配置连接
4. 为您的组织启用 SSO
<Callout type="info">
启用 SSO 后,团队成员将通过您的身份提供商进行身份验证,而不再使用邮箱/密码。
</Callout>
---
## 自主部署
对于自主部署场景,可通过环境变量启用企业功能:
| 变量 | 描述 |
|----------|-------------|
| `SSO_ENABLED``NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 |
| `CREDENTIAL_SETS_ENABLED``NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 |
<Callout type="warn">
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。
</Callout>

View File

@@ -50308,3 +50308,30 @@ checksums:
content/68: ba6b5020ed971cd7ffc7f0423650dfbf
content/69: b3f310d5ef115bea5a8b75bf25d7ea9a
content/70: 0362be478aa7ba4b6d1ebde0bd83e83a
f5bc5f89ed66818f4c485c554bf26eea:
meta/title: c70474271708e5b27392fde87462fa26
meta/description: 7b47db7fbb818c180b99354b912a72b3
content/0: 232be69c8f3053a40f695f9c9dcb3f2e
content/1: a4a62a6e782e18bd863546dfcf2aec1c
content/2: 51adf33450cab2ef392e93147386647c
content/3: ada515cf6e2e0f9d3f57f720f79699d3
content/4: d5e8b9f64d855675588845dc4124c491
content/5: 3acf1f0551f6097ca6159e66f5c8da1a
content/6: 6a6e277ded1a063ec2c2067abb519088
content/7: 6debcd334c3310480cbe6feab87f37b5
content/8: 0e3372052a2b3a1c43d853d6ed269d69
content/9: 90063613714128f4e61e9588e2d2c735
content/10: 182154179fe2a8b6b73fde0d04e0bf4c
content/11: 51adf33450cab2ef392e93147386647c
content/12: 73c3e8a5d36d6868fdb455fcb3d6074c
content/13: 30cd8f1d6197bce560a091ba19d0392a
content/14: 3acf1f0551f6097ca6159e66f5c8da1a
content/15: 997deef758698d207be9382c45301ad6
content/16: 6debcd334c3310480cbe6feab87f37b5
content/17: e26c8c2dffd70baef0253720c1511886
content/18: a99eba53979531f1c974cf653c346909
content/19: 51adf33450cab2ef392e93147386647c
content/20: ca3ec889fb218b8b130959ff04baa659
content/21: 306617201cf63b42f09bb72c9722e048
content/22: 4b48ba3f10b043f74b70edeb4ad87080
content/23: c8531bd570711abc1963d8b5dcf9deef

View File

@@ -1,272 +0,0 @@
/**
* A2A Agent Card Endpoint
*
* Returns the Agent Card (discovery document) for an A2A agent.
* Also supports CRUD operations for managing agents.
*/
import { db } from '@sim/db'
import { a2aAgent, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import type { AgentAuthentication, AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
const logger = createLogger('A2AAgentCardAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
/**
* GET - Returns the Agent Card for discovery
*/
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const [agent] = await db
.select({
agent: a2aAgent,
workflow: workflow,
})
.from(a2aAgent)
.innerJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
if (!agent.agent.isPublished) {
// Check if requester has access (for preview)
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
}
}
const agentCard = generateAgentCard(
{
id: agent.agent.id,
name: agent.agent.name,
description: agent.agent.description,
version: agent.agent.version,
capabilities: agent.agent.capabilities as AgentCapabilities,
skills: agent.agent.skills as AgentSkill[],
authentication: agent.agent.authentication as AgentAuthentication,
},
{
id: agent.workflow.id,
name: agent.workflow.name,
description: agent.workflow.description,
}
)
return NextResponse.json(agentCard, {
headers: {
'Content-Type': 'application/json',
'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
},
})
} catch (error) {
logger.error('Error getting Agent Card:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT - Update an agent
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const body = await request.json()
// Update agent
const [updatedAgent] = await db
.update(a2aAgent)
.set({
name: body.name ?? existingAgent.name,
description: body.description ?? existingAgent.description,
version: body.version ?? existingAgent.version,
capabilities: body.capabilities ?? existingAgent.capabilities,
skills: body.skills ?? existingAgent.skills,
authentication: body.authentication ?? existingAgent.authentication,
isPublished: body.isPublished ?? existingAgent.isPublished,
publishedAt:
body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
.returning()
logger.info(`Updated A2A agent: ${agentId}`)
return NextResponse.json({ success: true, agent: updatedAgent })
} catch (error) {
logger.error('Error updating agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE - Delete an agent
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
logger.info(`Deleted A2A agent: ${agentId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Publish/unpublish an agent
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn('A2A agent publish auth failed:', { error: auth.error, hasUserId: !!auth.userId })
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const body = await request.json()
const action = body.action as 'publish' | 'unpublish' | 'refresh'
if (action === 'publish') {
// Verify workflow is deployed
const [wf] = await db
.select({ isDeployed: workflow.isDeployed })
.from(workflow)
.where(eq(workflow.id, existingAgent.workflowId))
.limit(1)
if (!wf?.isDeployed) {
return NextResponse.json(
{ error: 'Workflow must be deployed before publishing agent' },
{ status: 400 }
)
}
await db
.update(a2aAgent)
.set({
isPublished: true,
publishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
logger.info(`Published A2A agent: ${agentId}`)
return NextResponse.json({ success: true, isPublished: true })
}
if (action === 'unpublish') {
await db
.update(a2aAgent)
.set({
isPublished: false,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
logger.info(`Unpublished A2A agent: ${agentId}`)
return NextResponse.json({ success: true, isPublished: false })
}
if (action === 'refresh') {
// Refresh skills from workflow
const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
if (!workflowData) {
return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
}
const [wf] = await db
.select({ name: workflow.name, description: workflow.description })
.from(workflow)
.where(eq(workflow.id, existingAgent.workflowId))
.limit(1)
const skills = generateSkillsFromWorkflow(
existingAgent.workflowId,
wf?.name || existingAgent.name,
wf?.description,
workflowData.blocks
)
await db
.update(a2aAgent)
.set({
skills,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
logger.info(`Refreshed skills for A2A agent: ${agentId}`)
return NextResponse.json({ success: true, skills })
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error('Error with agent action:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,197 +0,0 @@
/**
* A2A Agents List Endpoint
*
* List and create A2A agents for a workspace.
*/
import { db } from '@sim/db'
import { a2aAgent, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
import { sanitizeAgentName } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
const logger = createLogger('A2AAgentsAPI')
export const dynamic = 'force-dynamic'
/**
* GET - List all A2A agents for a workspace
*/
export async function GET(request: NextRequest) {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
// Verify workspace access
const [ws] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Get agents with workflow info
const agents = await db
.select({
id: a2aAgent.id,
workspaceId: a2aAgent.workspaceId,
workflowId: a2aAgent.workflowId,
name: a2aAgent.name,
description: a2aAgent.description,
version: a2aAgent.version,
capabilities: a2aAgent.capabilities,
skills: a2aAgent.skills,
authentication: a2aAgent.authentication,
isPublished: a2aAgent.isPublished,
publishedAt: a2aAgent.publishedAt,
createdAt: a2aAgent.createdAt,
updatedAt: a2aAgent.updatedAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
isDeployed: workflow.isDeployed,
taskCount: sql<number>`(
SELECT COUNT(*)::int
FROM "a2a_task"
WHERE "a2a_task"."agent_id" = "a2a_agent"."id"
)`.as('task_count'),
})
.from(a2aAgent)
.leftJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
.where(eq(a2aAgent.workspaceId, workspaceId))
.orderBy(a2aAgent.createdAt)
logger.info(`Listed ${agents.length} A2A agents for workspace ${workspaceId}`)
return NextResponse.json({ success: true, agents })
} catch (error) {
logger.error('Error listing agents:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Create a new A2A agent from a workflow
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { workspaceId, workflowId, name, description, capabilities, authentication } = body
if (!workspaceId || !workflowId) {
return NextResponse.json(
{ error: 'workspaceId and workflowId are required' },
{ status: 400 }
)
}
// Verify workflow exists and belongs to workspace
const [wf] = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
})
.from(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.workspaceId, workspaceId)))
.limit(1)
if (!wf) {
return NextResponse.json(
{ error: 'Workflow not found or does not belong to workspace' },
{ status: 404 }
)
}
// Check if agent already exists for this workflow
const [existing] = await db
.select({ id: a2aAgent.id })
.from(a2aAgent)
.where(and(eq(a2aAgent.workspaceId, workspaceId), eq(a2aAgent.workflowId, workflowId)))
.limit(1)
if (existing) {
return NextResponse.json(
{ error: 'An agent already exists for this workflow' },
{ status: 409 }
)
}
// Verify workflow has a start block
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
if (!workflowData || !hasValidStartBlockInState(workflowData)) {
return NextResponse.json(
{ error: 'Workflow must have a Start block to be exposed as an A2A agent' },
{ status: 400 }
)
}
// Generate skills from workflow
const skills = generateSkillsFromWorkflow(
workflowId,
name || wf.name,
description || wf.description,
workflowData.blocks
)
// Create agent
const agentId = uuidv4()
const agentName = name || sanitizeAgentName(wf.name)
const [agent] = await db
.insert(a2aAgent)
.values({
id: agentId,
workspaceId,
workflowId,
createdBy: auth.userId,
name: agentName,
description: description || wf.description,
version: '1.0.0',
capabilities: {
...A2A_DEFAULT_CAPABILITIES,
...capabilities,
},
skills,
authentication: authentication || {
schemes: ['bearer', 'apiKey'],
},
isPublished: false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
return NextResponse.json({ success: true, agent }, { status: 201 })
} catch (error) {
logger.error('Error creating agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,739 +0,0 @@
/**
* A2A Serve Endpoint - Implements A2A protocol for workflow agents
*
* Handles JSON-RPC 2.0 requests for:
* - tasks/send: Create or continue a task
* - tasks/get: Query task status
* - tasks/cancel: Cancel a running task
* - tasks/sendSubscribe: SSE streaming for real-time updates
*/
import { db } from '@sim/db'
import { a2aAgent, a2aTask, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { A2A_DEFAULT_TIMEOUT, A2A_METHODS } from '@/lib/a2a/constants'
import {
A2AErrorCode,
type Artifact,
type Task,
type TaskCancelParams,
type TaskMessage,
type TaskQueryParams,
type TaskSendParams,
type TaskState,
} from '@/lib/a2a/types'
import {
createAgentMessage,
createTaskStatus,
extractTextContent,
formatTaskResponse,
generateTaskId,
isTerminalState,
} from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('A2AServeAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
interface RouteParams {
agentId: string
}
interface JSONRPCRequest {
jsonrpc: '2.0'
id: string | number
method: string
params?: unknown
}
interface JSONRPCResponse {
jsonrpc: '2.0'
id: string | number | null
result?: unknown
error?: {
code: number
message: string
data?: unknown
}
}
function createResponse(id: string | number | null, result: unknown): JSONRPCResponse {
return { jsonrpc: '2.0', id, result }
}
function createError(
id: string | number | null,
code: number,
message: string,
data?: unknown
): JSONRPCResponse {
return { jsonrpc: '2.0', id, error: { code, message, data } }
}
function isJSONRPCRequest(obj: unknown): obj is JSONRPCRequest {
if (!obj || typeof obj !== 'object') return false
const r = obj as Record<string, unknown>
return r.jsonrpc === '2.0' && typeof r.method === 'string' && r.id !== undefined
}
/**
* GET - Returns the Agent Card (discovery document)
*
* This allows clients to discover the agent's capabilities by calling GET on the serve endpoint.
*/
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const [agent] = await db
.select({
id: a2aAgent.id,
name: a2aAgent.name,
description: a2aAgent.description,
version: a2aAgent.version,
capabilities: a2aAgent.capabilities,
skills: a2aAgent.skills,
authentication: a2aAgent.authentication,
isPublished: a2aAgent.isPublished,
})
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
if (!agent.isPublished) {
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
}
const baseUrl = getBaseUrl()
// Return full Agent Card for discovery
return NextResponse.json(
{
name: agent.name,
description: agent.description,
url: `${baseUrl}/api/a2a/serve/${agent.id}`,
version: agent.version,
documentationUrl: `${baseUrl}/docs/a2a`,
provider: {
organization: 'Sim Studio',
url: baseUrl,
},
capabilities: agent.capabilities,
skills: agent.skills,
authentication: agent.authentication,
defaultInputModes: ['text', 'data'],
defaultOutputModes: ['text', 'data'],
},
{
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
}
)
} catch (error) {
logger.error('Error getting Agent Card:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Handle JSON-RPC requests
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
// Verify agent exists and is published
const [agent] = await db
.select({
id: a2aAgent.id,
name: a2aAgent.name,
workflowId: a2aAgent.workflowId,
workspaceId: a2aAgent.workspaceId,
isPublished: a2aAgent.isPublished,
capabilities: a2aAgent.capabilities,
})
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!agent) {
return NextResponse.json(
createError(null, A2AErrorCode.AGENT_UNAVAILABLE, 'Agent not found'),
{ status: 404 }
)
}
if (!agent.isPublished) {
return NextResponse.json(
createError(null, A2AErrorCode.AGENT_UNAVAILABLE, 'Agent not published'),
{ status: 404 }
)
}
// Auth check
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json(
createError(null, A2AErrorCode.AUTHENTICATION_REQUIRED, 'Unauthorized'),
{ status: 401 }
)
}
// Verify workflow is deployed
const [wf] = await db
.select({ isDeployed: workflow.isDeployed })
.from(workflow)
.where(eq(workflow.id, agent.workflowId))
.limit(1)
if (!wf?.isDeployed) {
return NextResponse.json(
createError(null, A2AErrorCode.AGENT_UNAVAILABLE, 'Workflow is not deployed'),
{ status: 400 }
)
}
// Parse JSON-RPC request
const body = await request.json()
if (!isJSONRPCRequest(body)) {
return NextResponse.json(
createError(null, A2AErrorCode.INVALID_REQUEST, 'Invalid JSON-RPC request'),
{ status: 400 }
)
}
const { id, method, params: rpcParams } = body
const apiKey =
request.headers.get('X-API-Key') ||
request.headers.get('Authorization')?.replace('Bearer ', '')
logger.info(`A2A request: ${method} for agent ${agentId}`)
switch (method) {
case A2A_METHODS.TASKS_SEND:
return handleTaskSend(id, agent, rpcParams as TaskSendParams, apiKey)
case A2A_METHODS.TASKS_GET:
return handleTaskGet(id, rpcParams as TaskQueryParams)
case A2A_METHODS.TASKS_CANCEL:
return handleTaskCancel(id, rpcParams as TaskCancelParams)
case A2A_METHODS.TASKS_SEND_SUBSCRIBE:
return handleTaskSendSubscribe(request, id, agent, rpcParams as TaskSendParams, apiKey)
default:
return NextResponse.json(
createError(id, A2AErrorCode.METHOD_NOT_FOUND, `Method not found: ${method}`),
{ status: 404 }
)
}
} catch (error) {
logger.error('Error handling A2A request:', error)
return NextResponse.json(createError(null, A2AErrorCode.INTERNAL_ERROR, 'Internal error'), {
status: 500,
})
}
}
/**
* Handle tasks/send - Create or continue a task
*/
async function handleTaskSend(
id: string | number,
agent: {
id: string
name: string
workflowId: string
workspaceId: string
},
params: TaskSendParams,
apiKey?: string | null
): Promise<NextResponse> {
if (!params?.message) {
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Message is required'), {
status: 400,
})
}
const taskId = params.id || generateTaskId()
const contextId = params.contextId
// Check if task exists (continuation)
let existingTask: typeof a2aTask.$inferSelect | null = null
if (params.id) {
const [found] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
existingTask = found || null
if (!existingTask) {
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
status: 404,
})
}
if (isTerminalState(existingTask.status as TaskState)) {
return NextResponse.json(
createError(id, A2AErrorCode.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
{ status: 400 }
)
}
}
// Get existing history or start fresh
const history: TaskMessage[] = existingTask?.messages
? (existingTask.messages as TaskMessage[])
: []
// Add the new user message
history.push(params.message)
// Create or update task
if (existingTask) {
await db
.update(a2aTask)
.set({
status: 'working',
messages: history,
updatedAt: new Date(),
})
.where(eq(a2aTask.id, taskId))
} else {
await db.insert(a2aTask).values({
id: taskId,
agentId: agent.id,
sessionId: contextId || null,
status: 'working',
messages: history,
metadata: params.metadata || {},
createdAt: new Date(),
updatedAt: new Date(),
})
}
// Execute the workflow
const executeUrl = `${getBaseUrl()}/api/workflows/${agent.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['X-API-Key'] = apiKey
logger.info(`Executing workflow ${agent.workflowId} for A2A task ${taskId}`)
try {
// Extract text content from the TaskMessage for easier workflow consumption
const messageText = extractTextContent(params.message)
const response = await fetch(executeUrl, {
method: 'POST',
headers,
body: JSON.stringify({
input: messageText,
triggerType: 'api',
}),
signal: AbortSignal.timeout(A2A_DEFAULT_TIMEOUT),
})
const executeResult = await response.json()
// Determine final state
const finalState: TaskState = response.ok ? 'completed' : 'failed'
// Create agent response message
const agentContent =
executeResult.output?.content ||
(typeof executeResult.output === 'object'
? JSON.stringify(executeResult.output)
: String(executeResult.output || executeResult.error || 'Task completed'))
const agentMessage = createAgentMessage(agentContent)
history.push(agentMessage)
// Extract artifacts if present
const artifacts = executeResult.output?.artifacts || []
// Update task with result
await db
.update(a2aTask)
.set({
status: finalState,
messages: history,
artifacts,
executionId: executeResult.metadata?.executionId,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(a2aTask.id, taskId))
const task: Task = {
id: taskId,
contextId: contextId || undefined,
status: createTaskStatus(finalState),
history,
artifacts,
metadata: params.metadata,
kind: 'task',
}
return NextResponse.json(createResponse(id, task))
} catch (error) {
logger.error(`Error executing workflow for task ${taskId}:`, error)
// Mark task as failed
const errorMessage = error instanceof Error ? error.message : 'Workflow execution failed'
await db
.update(a2aTask)
.set({
status: 'failed',
updatedAt: new Date(),
completedAt: new Date(),
})
.where(eq(a2aTask.id, taskId))
return NextResponse.json(createError(id, A2AErrorCode.INTERNAL_ERROR, errorMessage), {
status: 500,
})
}
}
/**
* Handle tasks/get - Query task status
*/
async function handleTaskGet(id: string | number, params: TaskQueryParams): Promise<NextResponse> {
if (!params?.id) {
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Task ID is required'), {
status: 400,
})
}
// Validate historyLength if provided
const historyLength =
params.historyLength !== undefined && params.historyLength >= 0
? params.historyLength
: undefined
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
if (!task) {
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
status: 404,
})
}
const result = formatTaskResponse(
{
id: task.id,
contextId: task.sessionId || undefined,
status: createTaskStatus(task.status as TaskState),
history: task.messages as TaskMessage[],
artifacts: (task.artifacts as Artifact[]) || [],
metadata: (task.metadata as Record<string, unknown>) || {},
kind: 'task',
},
historyLength
)
return NextResponse.json(createResponse(id, result))
}
/**
* Handle tasks/cancel - Cancel a running task
*/
async function handleTaskCancel(
id: string | number,
params: TaskCancelParams
): Promise<NextResponse> {
if (!params?.id) {
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Task ID is required'), {
status: 400,
})
}
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
if (!task) {
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
status: 404,
})
}
if (isTerminalState(task.status as TaskState)) {
return NextResponse.json(
createError(id, A2AErrorCode.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
{ status: 400 }
)
}
await db
.update(a2aTask)
.set({
status: 'canceled',
updatedAt: new Date(),
completedAt: new Date(),
})
.where(eq(a2aTask.id, params.id))
const result: Task = {
id: task.id,
contextId: task.sessionId || undefined,
status: createTaskStatus('canceled'),
history: task.messages as TaskMessage[],
artifacts: (task.artifacts as Artifact[]) || [],
kind: 'task',
}
return NextResponse.json(createResponse(id, result))
}
/**
* Handle tasks/sendSubscribe - SSE streaming
*/
async function handleTaskSendSubscribe(
request: NextRequest,
id: string | number,
agent: {
id: string
name: string
workflowId: string
workspaceId: string
},
params: TaskSendParams,
apiKey?: string | null
): Promise<NextResponse> {
if (!params?.message) {
return NextResponse.json(createError(id, A2AErrorCode.INVALID_PARAMS, 'Message is required'), {
status: 400,
})
}
const contextId = params.contextId
// Get existing task or prepare for new one
let history: TaskMessage[] = []
let existingTask: typeof a2aTask.$inferSelect | null = null
if (params.id) {
const [found] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
existingTask = found || null
if (!existingTask) {
return NextResponse.json(createError(id, A2AErrorCode.TASK_NOT_FOUND, 'Task not found'), {
status: 404,
})
}
if (isTerminalState(existingTask.status as TaskState)) {
return NextResponse.json(
createError(id, A2AErrorCode.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
{ status: 400 }
)
}
history = existingTask.messages as TaskMessage[]
}
const taskId = params.id || generateTaskId()
history.push(params.message)
// Create or update task record
if (existingTask) {
await db
.update(a2aTask)
.set({
status: 'working',
messages: history,
updatedAt: new Date(),
})
.where(eq(a2aTask.id, taskId))
} else {
await db.insert(a2aTask).values({
id: taskId,
agentId: agent.id,
sessionId: contextId || null,
status: 'working',
messages: history,
metadata: params.metadata || {},
createdAt: new Date(),
updatedAt: new Date(),
})
}
// Create SSE stream
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (event: string, data: unknown) => {
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
} catch (error) {
logger.error('Error sending SSE event:', error)
}
}
// Send initial status
sendEvent('task:status', {
id: taskId,
status: { state: 'working', timestamp: new Date().toISOString() },
})
try {
// Execute workflow with streaming
const executeUrl = `${getBaseUrl()}/api/workflows/${agent.workflowId}/execute`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Stream-Response': 'true',
}
if (apiKey) headers['X-API-Key'] = apiKey
// Extract text content from the TaskMessage for easier workflow consumption
const messageText = extractTextContent(params.message)
const response = await fetch(executeUrl, {
method: 'POST',
headers,
body: JSON.stringify({
input: messageText,
triggerType: 'api',
stream: true,
}),
signal: AbortSignal.timeout(A2A_DEFAULT_TIMEOUT),
})
if (!response.ok) {
let errorMessage = 'Workflow execution failed'
try {
const errorResult = await response.json()
errorMessage = errorResult.error || errorMessage
} catch {
// Response may not be JSON
}
throw new Error(errorMessage)
}
// Check content type to determine response handling
const contentType = response.headers.get('content-type') || ''
const isStreamingResponse =
contentType.includes('text/event-stream') || contentType.includes('text/plain')
if (response.body && isStreamingResponse) {
// Handle streaming response - forward chunks
const reader = response.body.getReader()
const decoder = new TextDecoder()
let fullContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
fullContent += chunk
// Forward chunk as message event
sendEvent('task:message', {
id: taskId,
chunk,
})
}
// Create final agent message
const agentMessage = createAgentMessage(fullContent || 'Task completed')
history.push(agentMessage)
// Update task
await db
.update(a2aTask)
.set({
status: 'completed',
messages: history,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(a2aTask.id, taskId))
sendEvent('task:status', {
id: taskId,
status: { state: 'completed', timestamp: new Date().toISOString() },
final: true,
})
} else {
// Handle JSON response (non-streaming workflow)
const result = await response.json()
const content =
result.output?.content ||
(typeof result.output === 'object'
? JSON.stringify(result.output)
: String(result.output || 'Task completed'))
// Send the complete content as a single message
sendEvent('task:message', {
id: taskId,
chunk: content,
})
const agentMessage = createAgentMessage(content)
history.push(agentMessage)
const artifacts = (result.output?.artifacts as Artifact[]) || []
// Update task with result
await db
.update(a2aTask)
.set({
status: 'completed',
messages: history,
artifacts,
executionId: result.metadata?.executionId,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(a2aTask.id, taskId))
sendEvent('task:status', {
id: taskId,
status: { state: 'completed', timestamp: new Date().toISOString() },
final: true,
})
}
} catch (error) {
logger.error(`Streaming error for task ${taskId}:`, error)
await db
.update(a2aTask)
.set({
status: 'failed',
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(a2aTask.id, taskId))
sendEvent('error', {
code: A2AErrorCode.INTERNAL_ERROR,
message: error instanceof Error ? error.message : 'Streaming failed',
})
} finally {
sendEvent('task:done', { id: taskId })
controller.close()
}
},
})
return new NextResponse(stream, {
headers: {
...SSE_HEADERS,
'X-Task-Id': taskId,
},
})
}

View File

@@ -1,7 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { auth, getSession } from '@/lib/auth'
import { hasSSOAccess } from '@/lib/billing'
import { env } from '@/lib/core/config/env'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
@@ -63,10 +64,22 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
export async function POST(request: NextRequest) {
try {
// SSO plugin must be enabled in Better Auth
if (!env.SSO_ENABLED) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
}
// Check plan access (enterprise) or env var override
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const hasAccess = await hasSSOAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
}
const rawBody = await request.json()
const parseResult = ssoRegistrationSchema.safeParse(rawBody)

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -45,6 +46,15 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id, invitationId } = await params
try {

View File

@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -47,6 +48,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -69,6 +79,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {
@@ -178,6 +197,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const { searchParams } = new URL(req.url)
const invitationId = searchParams.get('invitationId')

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMembers')
@@ -39,6 +40,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -110,6 +120,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const { searchParams } = new URL(req.url)
const memberId = searchParams.get('memberId')

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSet')
@@ -49,6 +50,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -66,6 +76,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {
@@ -129,6 +148,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {

View File

@@ -5,6 +5,7 @@ import { and, count, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSets')
@@ -22,6 +23,15 @@ export async function GET(req: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { searchParams } = new URL(req.url)
const organizationId = searchParams.get('organizationId')
@@ -85,6 +95,15 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
try {
const body = await req.json()
const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body)

View File

@@ -0,0 +1,199 @@
/**
* Admin BYOK Keys API
*
* GET /api/v1/admin/byok
* List all BYOK keys with optional filtering.
*
* Query Parameters:
* - organizationId?: string - Filter by organization ID (finds all workspaces billed to this org)
* - workspaceId?: string - Filter by specific workspace ID
*
* Response: { data: AdminBYOKKey[], pagination: PaginationMeta }
*
* DELETE /api/v1/admin/byok
* Delete BYOK keys for an organization or workspace.
* Used when an enterprise plan churns to clean up BYOK keys.
*
* Query Parameters:
* - organizationId: string - Delete all BYOK keys for workspaces billed to this org
* - workspaceId?: string - Delete keys for a specific workspace only (optional)
*
* Response: { success: true, deletedCount: number, workspacesAffected: string[] }
*/
import { db } from '@sim/db'
import { user, workspace, workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminBYOKAPI')
export interface AdminBYOKKey {
id: string
workspaceId: string
workspaceName: string
organizationId: string
providerId: string
createdAt: string
createdByUserId: string | null
createdByEmail: string | null
}
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
const workspaceId = url.searchParams.get('workspaceId')
try {
let workspaceIds: string[] = []
if (workspaceId) {
workspaceIds = [workspaceId]
} else if (organizationId) {
const workspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.billedAccountUserId, organizationId))
workspaceIds = workspaces.map((w) => w.id)
}
const query = db
.select({
id: workspaceBYOKKeys.id,
workspaceId: workspaceBYOKKeys.workspaceId,
workspaceName: workspace.name,
organizationId: workspace.billedAccountUserId,
providerId: workspaceBYOKKeys.providerId,
createdAt: workspaceBYOKKeys.createdAt,
createdByUserId: workspaceBYOKKeys.createdBy,
createdByEmail: user.email,
})
.from(workspaceBYOKKeys)
.innerJoin(workspace, eq(workspaceBYOKKeys.workspaceId, workspace.id))
.leftJoin(user, eq(workspaceBYOKKeys.createdBy, user.id))
let keys
if (workspaceIds.length > 0) {
keys = await query.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
} else {
keys = await query
}
const formattedKeys: AdminBYOKKey[] = keys.map((k) => ({
id: k.id,
workspaceId: k.workspaceId,
workspaceName: k.workspaceName,
organizationId: k.organizationId,
providerId: k.providerId,
createdAt: k.createdAt.toISOString(),
createdByUserId: k.createdByUserId,
createdByEmail: k.createdByEmail,
}))
logger.info('Admin API: Listed BYOK keys', {
organizationId,
workspaceId,
count: formattedKeys.length,
})
return singleResponse({
data: formattedKeys,
pagination: {
total: formattedKeys.length,
limit: formattedKeys.length,
offset: 0,
hasMore: false,
},
})
} catch (error) {
logger.error('Admin API: Failed to list BYOK keys', { error, organizationId, workspaceId })
return internalErrorResponse('Failed to list BYOK keys')
}
})
export const DELETE = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
const workspaceId = url.searchParams.get('workspaceId')
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
if (!organizationId && !workspaceId) {
return badRequestResponse('Either organizationId or workspaceId is required')
}
try {
let workspaceIds: string[] = []
if (workspaceId) {
workspaceIds = [workspaceId]
} else if (organizationId) {
const workspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.billedAccountUserId, organizationId))
workspaceIds = workspaces.map((w) => w.id)
}
if (workspaceIds.length === 0) {
logger.info('Admin API: No workspaces found for BYOK cleanup', {
organizationId,
workspaceId,
})
return singleResponse({
success: true,
deletedCount: 0,
workspacesAffected: [],
message: 'No workspaces found for the given organization/workspace ID',
})
}
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(workspaceBYOKKeys)
.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
const totalToDelete = Number(countResult[0]?.count ?? 0)
if (totalToDelete === 0) {
logger.info('Admin API: No BYOK keys to delete', {
organizationId,
workspaceId,
workspaceIds,
})
return singleResponse({
success: true,
deletedCount: 0,
workspacesAffected: [],
message: 'No BYOK keys found for the specified workspaces',
})
}
await db.delete(workspaceBYOKKeys).where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
logger.info('Admin API: Deleted BYOK keys', {
organizationId,
workspaceId,
workspaceIds,
deletedCount: totalToDelete,
reason,
})
return singleResponse({
success: true,
deletedCount: totalToDelete,
workspacesAffected: workspaceIds,
reason,
})
} catch (error) {
logger.error('Admin API: Failed to delete BYOK keys', { error, organizationId, workspaceId })
return internalErrorResponse('Failed to delete BYOK keys')
}
})

View File

@@ -51,6 +51,10 @@
* GET /api/v1/admin/subscriptions - List all subscriptions
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
*
* BYOK Keys:
* GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
* DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'

View File

@@ -744,7 +744,7 @@ export async function POST(request: NextRequest) {
if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainHookId = await createGrainWebhookSubscription(
const grainResult = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
@@ -754,11 +754,12 @@ export async function POST(request: NextRequest) {
requestId
)
if (grainHookId) {
// Update the webhook record with the external Grain hook ID
if (grainResult) {
// Update the webhook record with the external Grain hook ID and event types for filtering
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainHookId,
externalId: grainResult.id,
eventTypes: grainResult.eventTypes,
}
await db
.update(webhook)
@@ -770,7 +771,8 @@ export async function POST(request: NextRequest) {
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId,
grainHookId: grainResult.id,
eventTypes: grainResult.eventTypes,
webhookId: savedWebhook.id,
})
}
@@ -1176,10 +1178,10 @@ async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<string | undefined> {
): Promise<{ id: string; eventTypes: string[] } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}
if (!apiKey) {
@@ -1191,12 +1193,53 @@ async function createGrainWebhookSubscription(
)
}
// Map trigger IDs to Grain API hook_type (only 2 options: recording_added, upload_status)
const hookTypeMap: Record<string, string> = {
grain_webhook: 'recording_added',
grain_recording_created: 'recording_added',
grain_recording_updated: 'recording_added',
grain_highlight_created: 'recording_added',
grain_highlight_updated: 'recording_added',
grain_story_created: 'recording_added',
grain_upload_status: 'upload_status',
}
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_created'],
grain_highlight_updated: ['highlight_updated'],
grain_story_created: ['story_created'],
grain_upload_status: ['upload_status'],
}
const hookType = hookTypeMap[triggerId] ?? 'recording_added'
const eventTypes = eventTypeMap[triggerId] ?? []
if (!hookTypeMap[triggerId]) {
logger.warn(
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`,
{
webhookId: webhookData.id,
}
)
}
logger.info(`[${requestId}] Creating Grain webhook`, {
triggerId,
hookType,
eventTypes,
webhookId: webhookData.id,
})
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
const requestBody: Record<string, any> = {
hook_url: notificationUrl,
hook_type: hookType,
}
// Build include object based on configuration
@@ -1226,8 +1269,10 @@ async function createGrainWebhookSubscription(
const responseBody = await grainResponse.json()
if (!grainResponse.ok || responseBody.error) {
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
logger.warn('[App] Grain response body:', responseBody)
const errorMessage =
responseBody.errors?.detail ||
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
@@ -1255,10 +1300,11 @@ async function createGrainWebhookSubscription(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
eventTypes,
}
)
return responseBody.id
return { id: responseBody.id, eventTypes }
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,

View File

@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -56,6 +58,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let byokEnabled = true
if (isHosted) {
byokEnabled = await isEnterpriseOrgAdminOrOwner(userId)
}
if (!byokEnabled) {
return NextResponse.json({ keys: [], byokEnabled: false })
}
const byokKeys = await db
.select({
id: workspaceBYOKKeys.id,
@@ -97,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
)
return NextResponse.json({ keys: formattedKeys })
return NextResponse.json({ keys: formattedKeys, byokEnabled: true })
} catch (error: unknown) {
logger.error(`[${requestId}] BYOK keys GET error`, error)
return NextResponse.json(
@@ -120,6 +131,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
if (isHosted) {
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
if (!canManageBYOK) {
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
return NextResponse.json(
{
error:
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
},
{ status: 403 }
)
}
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(
@@ -220,6 +245,20 @@ export async function DELETE(
const userId = session.user.id
if (isHosted) {
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
if (!canManageBYOK) {
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
return NextResponse.json(
{
error:
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
},
{ status: 403 }
)
}
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(

View File

@@ -175,7 +175,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollButton(distanceFromBottom > 100)
// Track if user is manually scrolling during streaming
if (isStreamingResponse && !isUserScrollingRef.current) {
setUserHasScrolled(true)
}
@@ -191,13 +190,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll])
// Reset user scroll tracking when streaming starts
useEffect(() => {
if (isStreamingResponse) {
// Reset userHasScrolled when streaming starts
setUserHasScrolled(false)
// Give a small delay to distinguish between programmatic scroll and user scroll
isUserScrollingRef.current = true
setTimeout(() => {
isUserScrollingRef.current = false
@@ -215,7 +211,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
})
if (!response.ok) {
// Check if auth is required
if (response.status === 401) {
const errorData = await response.json()
@@ -236,7 +231,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
throw new Error(`Failed to load chat configuration: ${response.status}`)
}
// Reset auth required state when authentication is successful
setAuthRequired(null)
const data = await response.json()
@@ -260,7 +254,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}
// Fetch chat config on mount and generate new conversation ID
useEffect(() => {
fetchChatConfig()
setConversationId(uuidv4())
@@ -285,7 +278,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}, 800)
}
// Handle sending a message
const handleSendMessage = async (
messageParam?: string,
isVoiceInput = false,
@@ -308,7 +300,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
filesCount: files?.length,
})
// Reset userHasScrolled when sending a new message
setUserHasScrolled(false)
const userMessage: ChatMessage = {
@@ -325,24 +316,20 @@ export default function ChatClient({ identifier }: { identifier: string }) {
})),
}
// Add the user's message to the chat
setMessages((prev) => [...prev, userMessage])
setInputValue('')
setIsLoading(true)
// Scroll to show only the user's message and loading indicator
setTimeout(() => {
scrollToMessage(userMessage.id, true)
}, 100)
// Create abort controller for request cancellation
const abortController = new AbortController()
const timeoutId = setTimeout(() => {
abortController.abort()
}, CHAT_REQUEST_TIMEOUT_MS)
try {
// Send structured payload to maintain chat context
const payload: any = {
input:
typeof userMessage.content === 'string'
@@ -351,7 +338,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
conversationId,
}
// Add files if present (convert to base64 for JSON transmission)
if (files && files.length > 0) {
payload.files = await Promise.all(
files.map(async (file) => ({
@@ -379,7 +365,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
signal: abortController.signal,
})
// Clear timeout since request succeeded
clearTimeout(timeoutId)
if (!response.ok) {
@@ -392,7 +377,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
throw new Error('Response body is missing')
}
// Use the streaming hook with audio support
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
const audioHandler = shouldPlayAudio
? createAudioStreamHandler(
@@ -421,7 +405,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
)
} catch (error: any) {
// Clear timeout in case of error
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
@@ -442,7 +425,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}
// Stop audio when component unmounts or when streaming is stopped
useEffect(() => {
return () => {
stopAudio()
@@ -452,28 +434,23 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}, [stopAudio])
// Voice interruption - stop audio when user starts speaking
const handleVoiceInterruption = useCallback(() => {
stopAudio()
// Stop any ongoing streaming response
if (isStreamingResponse) {
stopStreaming(setMessages)
}
}, [isStreamingResponse, stopStreaming, setMessages, stopAudio])
// Handle voice mode activation
const handleVoiceStart = useCallback(() => {
setIsVoiceFirstMode(true)
}, [])
// Handle exiting voice mode
const handleExitVoiceMode = useCallback(() => {
setIsVoiceFirstMode(false)
stopAudio() // Stop any playing audio when exiting
stopAudio()
}, [stopAudio])
// Handle voice transcript from voice-first interface
const handleVoiceTranscript = useCallback(
(transcript: string) => {
logger.info('Received voice transcript:', transcript)
@@ -482,14 +459,11 @@ export default function ChatClient({ identifier }: { identifier: string }) {
[handleSendMessage]
)
// If error, show error message using the extracted component
if (error) {
return <ChatErrorState error={error} starCount={starCount} />
}
// If authentication is required, use the extracted components
if (authRequired) {
// Get title and description from the URL params or use defaults
const title = new URLSearchParams(window.location.search).get('title') || 'chat'
const primaryColor =
new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
@@ -526,12 +500,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}
// Loading state while fetching config using the extracted component
if (!chatConfig) {
return <ChatLoadingState />
}
// Voice-first mode interface
if (isVoiceFirstMode) {
return (
<VoiceInterface
@@ -551,7 +523,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
)
}
// Standard text-based chat interface
return (
<div className='fixed inset-0 z-[100] flex flex-col bg-white text-foreground'>
{/* Header component */}

View File

@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'

View File

@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

View File

@@ -1,7 +1,7 @@
export { Dashboard } from './dashboard'
export { LogDetails } from './log-details'
export { ExecutionSnapshot } from './log-details/components/execution-snapshot'
export { FileCards } from './log-details/components/file-download'
export { FrozenCanvas } from './log-details/components/frozen-canvas'
export { TraceSpans } from './log-details/components/trace-spans'
export { LogRowContextMenu } from './log-row-context-menu'
export { LogsList } from './logs-list'

View File

@@ -0,0 +1,224 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
BlockDetailsSidebar,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface TraceSpan {
blockId?: string
input?: unknown
output?: unknown
status?: string
duration?: number
children?: TraceSpan[]
}
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
}
interface MigratedWorkflowState extends WorkflowState {
_migrated: true
_note?: string
}
function isMigratedWorkflowState(state: WorkflowState): state is MigratedWorkflowState {
return (state as MigratedWorkflowState)._migrated === true
}
interface ExecutionSnapshotProps {
executionId: string
traceSpans?: TraceSpan[]
className?: string
height?: string | number
width?: string | number
isModal?: boolean
isOpen?: boolean
onClose?: () => void
}
export function ExecutionSnapshot({
executionId,
traceSpans,
className,
height = '100%',
width = '100%',
isModal = false,
isOpen = false,
onClose = () => {},
}: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const blockExecutions = useMemo(() => {
if (!traceSpans || !Array.isArray(traceSpans)) return {}
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of spans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
}
}
}
return blockExecutionMap
}, [traceSpans])
useEffect(() => {
setPinnedBlockId(null)
}, [executionId])
const workflowState = data?.workflowState as WorkflowState | undefined
const renderContent = () => {
if (isLoading) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading execution snapshot...</span>
</div>
</div>
)
}
if (error) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Failed to load execution snapshot: {error.message}</span>
</div>
</div>
)
}
if (!data || !workflowState) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading execution snapshot...</span>
</div>
</div>
)
}
if (isMigratedWorkflowState(workflowState)) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
<AlertCircle className='h-[20px] w-[20px]' />
<span className='font-medium text-[15px]'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
This log was migrated from the old logging system. The workflow state at execution time
is not available.
</div>
<div className='text-[12px] text-[var(--text-tertiary)]'>Note: {workflowState._note}</div>
</div>
)
}
return (
<div
style={{ height, width }}
className={cn(
'flex overflow-hidden rounded-[4px] border border-[var(--border)]',
className
)}
>
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowState}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId((prev) => (prev === blockId ? null : blockId))
}}
cursorStyle='pointer'
executedBlocks={blockExecutions}
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<BlockDetailsSidebar
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
isExecutionMode
/>
)}
</div>
)
}
if (isModal) {
return (
<Modal
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setPinnedBlockId(null)
onClose()
}
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'>{renderContent()}</ModalBody>
</ModalContent>
</Modal>
)
}
return renderContent()
}

View File

@@ -0,0 +1 @@
export { ExecutionSnapshot } from './execution-snapshot'

View File

@@ -1,657 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
AlertCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
DollarSign,
Hash,
Loader2,
Maximize2,
X,
Zap,
} from 'lucide-react'
import { Badge, Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('FrozenCanvas')
function ExpandableDataSection({ title, data }: { title: string; data: any }) {
const [isExpanded, setIsExpanded] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const jsonString = JSON.stringify(data, null, 2)
const isLargeData = jsonString.length > 500 || jsonString.split('\n').length > 10
return (
<>
<div>
<div className='mb-[6px] flex items-center justify-between'>
<h4 className='font-medium text-[13px] text-[var(--text-primary)]'>{title}</h4>
<div className='flex items-center gap-[4px]'>
{isLargeData && (
<button
onClick={() => setIsModalOpen(true)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
title='Expand in modal'
type='button'
>
<Maximize2 className='h-[14px] w-[14px]' />
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
{isExpanded ? (
<ChevronUp className='h-[14px] w-[14px]' />
) : (
<ChevronDown className='h-[14px] w-[14px]' />
)}
</button>
</div>
</div>
<div
className={cn(
'overflow-y-auto rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] font-mono text-[12px] transition-all duration-200',
isExpanded ? 'max-h-96' : 'max-h-32'
)}
>
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
</div>
{isModalOpen && (
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
<div className='mx-[16px] flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<div className='flex items-center justify-between border-[var(--border)] border-b p-[16px]'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>{title}</h3>
<button
onClick={() => setIsModalOpen(false)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex-1 overflow-auto p-[16px]'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
</div>
</div>
)}
</>
)
}
function formatExecutionData(executionData: any) {
const {
inputData,
outputData,
cost,
tokens,
durationMs,
status,
blockName,
blockType,
errorMessage,
errorStackTrace,
} = executionData
return {
blockName: blockName || 'Unknown Block',
blockType: blockType || 'unknown',
status,
duration: durationMs ? `${durationMs}ms` : 'N/A',
input: redactApiKeys(inputData || {}),
output: redactApiKeys(outputData || {}),
errorMessage,
errorStackTrace,
cost: cost
? {
input: cost.input || 0,
output: cost.output || 0,
total: cost.total || 0,
}
: null,
tokens: tokens
? {
input: tokens.input || tokens.prompt || 0,
output: tokens.output || tokens.completion || 0,
total: tokens.total || 0,
}
: null,
}
}
function getCurrentIterationData(blockExecutionData: any) {
if (blockExecutionData.iterations && Array.isArray(blockExecutionData.iterations)) {
const currentIndex = blockExecutionData.currentIteration ?? 0
return {
executionData: blockExecutionData.iterations[currentIndex],
currentIteration: currentIndex,
totalIterations: blockExecutionData.totalIterations ?? blockExecutionData.iterations.length,
hasMultipleIterations: blockExecutionData.iterations.length > 1,
}
}
return {
executionData: blockExecutionData,
currentIteration: 0,
totalIterations: 1,
hasMultipleIterations: false,
}
}
function PinnedLogs({
executionData,
blockId,
workflowState,
onClose,
}: {
executionData: any | null
blockId: string
workflowState: any
onClose: () => void
}) {
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
useEffect(() => {
setCurrentIterationIndex(0)
}, [executionData])
if (!executionData) {
const blockInfo = workflowState?.blocks?.[blockId]
const formatted = {
blockName: blockInfo?.name || 'Unknown Block',
blockType: blockInfo?.type || 'unknown',
status: 'not_executed',
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex items-center gap-[8px]'>
<Badge variant='gray-secondary'>{formatted.blockType}</Badge>
<Badge variant='outline'>not executed</Badge>
</div>
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px] text-center'>
<div className='text-[13px] text-[var(--text-secondary)]'>
This block was not executed because the workflow failed before reaching it.
</div>
</div>
</CardContent>
</Card>
)
}
const iterationInfo = getCurrentIterationData({
...executionData,
currentIteration: currentIterationIndex,
})
const formatted = formatExecutionData(iterationInfo.executionData)
const totalIterations = executionData.iterations?.length || 1
const goToPreviousIteration = () => {
if (currentIterationIndex > 0) {
setCurrentIterationIndex(currentIterationIndex - 1)
}
}
const goToNextIteration = () => {
if (currentIterationIndex < totalIterations - 1) {
setCurrentIterationIndex(currentIterationIndex + 1)
}
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<Badge variant={formatted.status === 'success' ? 'default' : 'red'}>
{formatted.blockType}
</Badge>
<Badge variant='outline'>{formatted.status}</Badge>
</div>
{iterationInfo.hasMultipleIterations && (
<div className='flex items-center gap-[4px]'>
<button
onClick={goToPreviousIteration}
disabled={currentIterationIndex === 0}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
type='button'
>
<ChevronLeft className='h-[14px] w-[14px]' />
</button>
<span className='px-[8px] text-[12px] text-[var(--text-tertiary)]'>
{iterationInfo.totalIterations !== undefined
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
: `${currentIterationIndex + 1}`}
</span>
<button
onClick={goToNextIteration}
disabled={currentIterationIndex === totalIterations - 1}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
type='button'
>
<ChevronRight className='h-[14px] w-[14px]' />
</button>
</div>
)}
</div>
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex items-center gap-[8px]'>
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>{formatted.duration}</span>
</div>
{formatted.cost && formatted.cost.total > 0 && (
<div className='flex items-center gap-[8px]'>
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>
${formatted.cost.total.toFixed(5)}
</span>
</div>
)}
{formatted.tokens && formatted.tokens.total > 0 && (
<div className='flex items-center gap-[8px]'>
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>
{formatted.tokens.total} tokens
</span>
</div>
)}
</div>
<ExpandableDataSection title='Input' data={formatted.input} />
<ExpandableDataSection title='Output' data={formatted.output} />
{formatted.cost && formatted.cost.total > 0 && (
<div>
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
Cost Breakdown
</h4>
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Input:</span>
<span>${formatted.cost.input.toFixed(5)}</span>
</div>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Output:</span>
<span>${formatted.cost.output.toFixed(5)}</span>
</div>
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
<span>Total:</span>
<span>${formatted.cost.total.toFixed(5)}</span>
</div>
</div>
</div>
)}
{formatted.tokens && formatted.tokens.total > 0 && (
<div>
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
Token Usage
</h4>
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Input:</span>
<span>{formatted.tokens.input}</span>
</div>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Output:</span>
<span>{formatted.tokens.output}</span>
</div>
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
<span>Total:</span>
<span>{formatted.tokens.total}</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)
}
interface FrozenCanvasData {
executionId: string
workflowId: string
workflowState: WorkflowState
executionMetadata: {
trigger: string
startedAt: string
endedAt?: string
totalDurationMs?: number
cost: {
total: number | null
input: number | null
output: number | null
}
totalTokens: number | null
}
}
interface FrozenCanvasProps {
executionId: string
traceSpans?: any[]
className?: string
height?: string | number
width?: string | number
isModal?: boolean
isOpen?: boolean
onClose?: () => void
}
export function FrozenCanvas({
executionId,
traceSpans,
className,
height = '100%',
width = '100%',
isModal = false,
isOpen = false,
onClose,
}: FrozenCanvasProps) {
const [data, setData] = useState<FrozenCanvasData | null>(null)
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
// Process traceSpans to create blockExecutions map
useEffect(() => {
if (traceSpans && Array.isArray(traceSpans)) {
const blockExecutionMap: Record<string, any> = {}
logger.debug('Processing trace spans for frozen canvas:', { traceSpans })
// Recursively collect all spans with blockId from the trace spans tree
const collectBlockSpans = (spans: any[]): any[] => {
const blockSpans: any[] = []
for (const span of spans) {
// If this span has a blockId, it's a block execution
if (span.blockId) {
blockSpans.push(span)
}
// Recursively check children
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
logger.debug('Collected all block spans:', allBlockSpans)
// Group spans by blockId
const traceSpansByBlockId = allBlockSpans.reduce((acc: any, span: any) => {
if (span.blockId) {
if (!acc[span.blockId]) {
acc[span.blockId] = []
}
acc[span.blockId].push(span)
}
return acc
}, {})
logger.debug('Grouped trace spans by blockId:', traceSpansByBlockId)
for (const [blockId, spans] of Object.entries(traceSpansByBlockId)) {
const spanArray = spans as any[]
const iterations = spanArray.map((span: any) => {
// Extract error information from span output if status is error
let errorMessage = null
let errorStackTrace = null
if (span.status === 'error' && span.output) {
// Error information can be in different formats in the output
if (typeof span.output === 'string') {
errorMessage = span.output
} else if (span.output.error) {
errorMessage = span.output.error
errorStackTrace = span.output.stackTrace || span.output.stack
} else if (span.output.message) {
errorMessage = span.output.message
errorStackTrace = span.output.stackTrace || span.output.stack
} else {
// Fallback: stringify the entire output for error cases
errorMessage = JSON.stringify(span.output)
}
}
return {
id: span.id,
blockId: span.blockId,
blockName: span.name,
blockType: span.type,
status: span.status,
startedAt: span.startTime,
endedAt: span.endTime,
durationMs: span.duration,
inputData: span.input,
outputData: span.output,
errorMessage,
errorStackTrace,
cost: span.cost || {
input: null,
output: null,
total: null,
},
tokens: span.tokens || {
input: null,
output: null,
total: null,
},
modelUsed: span.model || null,
metadata: {},
}
})
blockExecutionMap[blockId] = {
iterations,
currentIteration: 0,
totalIterations: iterations.length,
}
}
setBlockExecutions(blockExecutionMap)
}
}, [traceSpans])
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(`/api/logs/execution/${executionId}`)
if (!response.ok) {
throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`)
}
const result = await response.json()
setData(result)
logger.debug(`Loaded frozen canvas data for execution: ${executionId}`)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
logger.error('Failed to fetch frozen canvas data:', err)
setError(errorMessage)
} finally {
setLoading(false)
}
}
fetchData()
}, [executionId])
const renderContent = () => {
if (loading) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading frozen canvas...</span>
</div>
</div>
)
}
if (error) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
</div>
</div>
)
}
if (!data) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='text-[13px] text-[var(--text-secondary)]'>No data available</div>
</div>
)
}
const isMigratedLog = (data.workflowState as any)?._migrated === true
if (isMigratedLog) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
<AlertCircle className='h-[20px] w-[20px]' />
<span className='font-medium text-[15px]'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
This log was migrated from the old logging system. The workflow state at execution time
is not available.
</div>
<div className='text-[12px] text-[var(--text-tertiary)]'>
Note: {(data.workflowState as any)?._note}
</div>
</div>
)
}
return (
<>
<div
style={{ height, width }}
className={cn('frozen-canvas-mode h-full w-full', className)}
>
<WorkflowPreview
workflowState={data.workflowState}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId(blockId)
}}
/>
</div>
{pinnedBlockId && (
<PinnedLogs
executionData={blockExecutions[pinnedBlockId] || null}
blockId={pinnedBlockId}
workflowState={data.workflowState}
onClose={() => setPinnedBlockId(null)}
/>
)}
</>
)
}
if (isModal) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='xl' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='min-h-0 flex-1'>
<div className='flex h-full flex-col'>
<div className='min-h-0 flex-1 overflow-hidden rounded-[4px] border border-[var(--border)]'>
{renderContent()}
</div>
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}
return renderContent()
}

View File

@@ -1 +0,0 @@
export { FrozenCanvas } from './frozen-canvas'

View File

@@ -5,7 +5,11 @@ import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
import {
ExecutionSnapshot,
FileCards,
TraceSpans,
} from '@/app/workspace/[workspaceId]/logs/components'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import {
formatDate,
@@ -49,7 +53,7 @@ export const LogDetails = memo(function LogDetails({
hasNext = false,
hasPrev = false,
}: LogDetailsProps) {
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const { handleMouseDown } = useLogDetailsResize()
@@ -266,7 +270,7 @@ export const LogDetails = memo(function LogDetails({
Workflow State
</span>
<button
onClick={() => setIsFrozenCanvasOpen(true)}
onClick={() => setIsExecutionSnapshotOpen(true)}
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--surface-4)]'
>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
@@ -363,12 +367,12 @@ export const LogDetails = memo(function LogDetails({
{/* Frozen Canvas Modal */}
{log?.executionId && (
<FrozenCanvas
<ExecutionSnapshot
executionId={log.executionId}
traceSpans={log.executionData?.traceSpans}
isModal
isOpen={isFrozenCanvasOpen}
onClose={() => setIsFrozenCanvasOpen(false)}
isOpen={isExecutionSnapshotOpen}
onClose={() => setIsExecutionSnapshotOpen(false)}
/>
)}
</div>

View File

@@ -18,6 +18,7 @@ interface LogRowContextMenuProps {
log: WorkflowLog | null
onCopyExecutionId: () => void
onOpenWorkflow: () => void
onOpenPreview: () => void
onToggleWorkflowFilter: () => void
onClearAllFilters: () => void
isFilteredByThisWorkflow: boolean
@@ -36,6 +37,7 @@ export function LogRowContextMenu({
log,
onCopyExecutionId,
onOpenWorkflow,
onOpenPreview,
onToggleWorkflowFilter,
onClearAllFilters,
isFilteredByThisWorkflow,
@@ -78,6 +80,15 @@ export function LogRowContextMenu({
>
Open Workflow
</PopoverItem>
<PopoverItem
disabled={!hasExecutionId}
onClick={() => {
onOpenPreview()
onClose()
}}
>
Open Preview
</PopoverItem>
{/* Filter actions */}
<PopoverDivider />

View File

@@ -18,6 +18,7 @@ import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import {
Dashboard,
ExecutionSnapshot,
LogDetails,
LogRowContextMenu,
LogsList,
@@ -59,8 +60,7 @@ export default function Logs() {
setWorkspaceId(workspaceId)
}, [workspaceId, setWorkspaceId])
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
@@ -90,6 +90,12 @@ export default function Logs() {
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const activeLogQuery = useLogDetail(activeLogId ?? undefined)
const logFilters = useMemo(
() => ({
timeRange,
@@ -129,19 +135,23 @@ export default function Logs() {
refetchInterval: isLive ? 5000 : false,
})
const logDetailQuery = useLogDetail(selectedLog?.id)
const mergedSelectedLog = useMemo(() => {
if (!selectedLog) return null
if (!logDetailQuery.data) return selectedLog
return { ...selectedLog, ...logDetailQuery.data }
}, [selectedLog, logDetailQuery.data])
const logs = useMemo(() => {
if (!logsQuery.data?.pages) return []
return logsQuery.data.pages.flatMap((page) => page.logs)
}, [logsQuery.data?.pages])
const selectedLogIndex = useMemo(
() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1),
[logs, selectedLogId]
)
const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null
const selectedLog = useMemo(() => {
if (!selectedLogFromList) return null
if (!activeLogQuery.data || isPreviewOpen) return selectedLogFromList
return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
useFolders(workspaceId)
useEffect(() => {
@@ -150,89 +160,40 @@ export default function Logs() {
}
}, [debouncedSearchQuery, setStoreSearchQuery])
const prevSelectedLogRef = useRef<WorkflowLog | null>(null)
useEffect(() => {
if (!selectedLog?.id || logs.length === 0) return
const updatedLog = logs.find((l) => l.id === selectedLog.id)
if (!updatedLog) return
const prevLog = prevSelectedLogRef.current
const hasStatusChange =
prevLog?.id === updatedLog.id &&
(updatedLog.duration !== prevLog.duration || updatedLog.status !== prevLog.status)
if (updatedLog !== selectedLog) {
setSelectedLog(updatedLog)
prevSelectedLogRef.current = updatedLog
}
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
if (newIndex !== selectedLogIndex) {
setSelectedLogIndex(newIndex)
}
if (hasStatusChange) {
logDetailQuery.refetch()
}
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery])
useEffect(() => {
if (!isLive || !selectedLog?.id) return
const interval = setInterval(() => {
logDetailQuery.refetch()
}, 5000)
if (!isLive || !selectedLogId) return
const interval = setInterval(() => activeLogQuery.refetch(), 5000)
return () => clearInterval(interval)
}, [isLive, selectedLog?.id, logDetailQuery])
}, [isLive, selectedLogId, activeLogQuery])
const handleLogClick = useCallback(
(log: WorkflowLog) => {
if (selectedLog?.id === log.id && isSidebarOpen) {
if (selectedLogId === log.id && isSidebarOpen) {
setIsSidebarOpen(false)
setSelectedLog(null)
setSelectedLogIndex(-1)
prevSelectedLogRef.current = null
setSelectedLogId(null)
return
}
setSelectedLog(log)
prevSelectedLogRef.current = log
const index = logs.findIndex((l) => l.id === log.id)
setSelectedLogIndex(index)
setSelectedLogId(log.id)
setIsSidebarOpen(true)
},
[selectedLog?.id, isSidebarOpen, logs]
[selectedLogId, isSidebarOpen]
)
const handleNavigateNext = useCallback(() => {
if (selectedLogIndex < logs.length - 1) {
const nextIndex = selectedLogIndex + 1
setSelectedLogIndex(nextIndex)
const nextLog = logs[nextIndex]
setSelectedLog(nextLog)
prevSelectedLogRef.current = nextLog
setSelectedLogId(logs[selectedLogIndex + 1].id)
}
}, [selectedLogIndex, logs])
const handleNavigatePrev = useCallback(() => {
if (selectedLogIndex > 0) {
const prevIndex = selectedLogIndex - 1
setSelectedLogIndex(prevIndex)
const prevLog = logs[prevIndex]
setSelectedLog(prevLog)
prevSelectedLogRef.current = prevLog
setSelectedLogId(logs[selectedLogIndex - 1].id)
}
}, [selectedLogIndex, logs])
const handleCloseSidebar = useCallback(() => {
setIsSidebarOpen(false)
setSelectedLog(null)
setSelectedLogIndex(-1)
prevSelectedLogRef.current = null
setSelectedLogId(null)
}, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
@@ -271,6 +232,13 @@ export default function Logs() {
setSearchQuery('')
}, [resetFilters, setSearchQuery])
const handleOpenPreview = useCallback(() => {
if (contextMenuLog?.id) {
setPreviewLogId(contextMenuLog.id)
setIsPreviewOpen(true)
}
}, [contextMenuLog])
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
const isFilteredByThisWorkflow = Boolean(
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
@@ -298,10 +266,10 @@ export default function Logs() {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch()
if (selectedLog?.id) {
logDetailQuery.refetch()
if (selectedLogId) {
activeLogQuery.refetch()
}
}, [logsQuery, logDetailQuery, selectedLog?.id])
}, [logsQuery, activeLogQuery, selectedLogId])
const handleToggleLive = useCallback(() => {
const newIsLive = !isLive
@@ -393,9 +361,7 @@ export default function Logs() {
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault()
setSelectedLogIndex(0)
setSelectedLog(logs[0])
prevSelectedLogRef.current = logs[0]
setSelectedLogId(logs[0].id)
return
}
@@ -409,7 +375,7 @@ export default function Logs() {
handleNavigateNext()
}
if (e.key === 'Enter' && selectedLog) {
if (e.key === 'Enter' && selectedLogId) {
e.preventDefault()
setIsSidebarOpen(!isSidebarOpen)
}
@@ -417,7 +383,7 @@ export default function Logs() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [logs, selectedLogIndex, isSidebarOpen, selectedLog, handleNavigateNext, handleNavigatePrev])
}, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
const isDashboardView = viewMode === 'dashboard'
@@ -509,7 +475,7 @@ export default function Logs() {
) : (
<LogsList
logs={logs}
selectedLogId={selectedLog?.id ?? null}
selectedLogId={selectedLogId}
onLogClick={handleLogClick}
onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef}
@@ -524,7 +490,7 @@ export default function Logs() {
{/* Log Details - rendered inside table container */}
<LogDetails
log={mergedSelectedLog}
log={selectedLog}
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}
@@ -550,11 +516,25 @@ export default function Logs() {
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow}
onOpenPreview={handleOpenPreview}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
onClearAllFilters={handleClearAllFilters}
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
hasActiveFilters={filtersActive}
/>
{isPreviewOpen && activeLogQuery.data?.executionId && (
<ExecutionSnapshot
executionId={activeLogQuery.data.executionId}
traceSpans={activeLogQuery.data.executionData?.traceSpans}
isModal
isOpen={isPreviewOpen}
onClose={() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}}
/>
)}
</div>
)
}

View File

@@ -1,16 +1,13 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
interface TemplateCardProps {
id: string
title: string

View File

@@ -3,8 +3,8 @@
import { memo, useMemo } from 'react'
import { useViewport } from 'reactflow'
import { useSession } from '@/lib/auth/auth-client'
import { getUserColor } from '@/lib/workspaces/colors'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { useSocket } from '@/app/workspace/providers/socket-provider'
interface CursorPoint {

View File

@@ -1,7 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Maximize2 } from 'lucide-react'
import {
Button,
Label,
@@ -10,10 +11,15 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
BlockDetailsSidebar,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { Versions } from './components'
@@ -49,48 +55,26 @@ export function GeneralDeploy({
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
const versionCacheRef = useRef<Map<number, WorkflowState>>(new Map())
const [, forceUpdate] = useState({})
const selectedVersionInfo = versions.find((v) => v.version === selectedVersion)
const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote)
const versionToLoadInfo = versions.find((v) => v.version === versionToLoad)
const cachedSelectedState =
selectedVersion !== null ? versionCacheRef.current.get(selectedVersion) : null
const { data: selectedVersionState } = useDeploymentVersionState(workflowId, selectedVersion)
const fetchSelectedVersionState = useCallback(
async (version: number) => {
if (!workflowId) return
if (versionCacheRef.current.has(version)) return
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
if (res.ok) {
const data = await res.json()
if (data.deployedState) {
versionCacheRef.current.set(version, data.deployedState)
forceUpdate({})
}
}
} catch (error) {
logger.error('Error fetching version state:', error)
}
},
[workflowId]
)
const revertMutation = useRevertToVersion()
useEffect(() => {
if (selectedVersion !== null) {
fetchSelectedVersionState(selectedVersion)
setPreviewMode('selected')
} else {
setPreviewMode('active')
}
}, [selectedVersion, fetchSelectedVersionState])
}, [selectedVersion])
const handleSelectVersion = useCallback((version: number | null) => {
setSelectedVersion(version)
@@ -109,20 +93,12 @@ export function GeneralDeploy({
const confirmLoadDeployment = async () => {
if (!workflowId || versionToLoad === null) return
// Close modal immediately for snappy UX
setShowLoadDialog(false)
const version = versionToLoad
setVersionToLoad(null)
try {
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to load deployment')
}
await revertMutation.mutateAsync({ workflowId, version })
onLoadDeploymentComplete()
} catch (error) {
logger.error('Failed to load deployment:', error)
@@ -132,7 +108,6 @@ export function GeneralDeploy({
const confirmPromoteToLive = async () => {
if (versionToPromote === null) return
// Close modal immediately for snappy UX
setShowPromoteDialog(false)
const version = versionToPromote
setVersionToPromote(null)
@@ -145,15 +120,14 @@ export function GeneralDeploy({
}
const workflowToShow = useMemo(() => {
if (previewMode === 'selected' && cachedSelectedState) {
return cachedSelectedState
if (previewMode === 'selected' && selectedVersionState) {
return selectedVersionState
}
return deployedState
}, [previewMode, cachedSelectedState, deployedState])
}, [previewMode, selectedVersionState, deployedState])
const showToggle = selectedVersion !== null && deployedState
// Only show skeleton on initial load when we have no deployed data
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
@@ -219,15 +193,31 @@ export function GeneralDeploy({
}}
>
{workflowToShow ? (
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
<>
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
size='sm'
onClick={() => setShowExpandedPreview(true)}
className='absolute top-[8px] right-[8px] z-10'
>
<Maximize2 className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Expand preview</Tooltip.Content>
</Tooltip.Root>
</>
) : (
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
Deploy your workflow to see a preview
@@ -304,6 +294,51 @@ export function GeneralDeploy({
</ModalFooter>
</ModalContent>
</Modal>
{workflowToShow && (
<Modal
open={showExpandedPreview}
onOpenChange={(open) => {
if (!open) {
setExpandedSelectedBlockId(null)
}
setShowExpandedPreview(open)
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>
{previewMode === 'selected' && selectedVersionInfo
? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'}
</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'>
<div className='flex h-full w-full overflow-hidden'>
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
onNodeClick={(blockId) => {
setExpandedSelectedBlockId(
expandedSelectedBlockId === blockId ? null : blockId
)
}}
cursorStyle='pointer'
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<BlockDetailsSidebar
block={workflowToShow.blocks[expandedSelectedBlockId]}
onClose={() => setExpandedSelectedBlockId(null)}
/>
)}
</div>
</ModalBody>
</ModalContent>
</Modal>
)}
</>
)
}

View File

@@ -18,7 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import {
useCreateTemplate,
useDeleteTemplate,

View File

@@ -332,7 +332,10 @@ export function LongInput({
/>
<div
ref={overlayRef}
className='pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm'
className={cn(
'pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm',
(isPreview || disabled) && 'opacity-50'
)}
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',

View File

@@ -374,7 +374,8 @@ export function ShortInput({
ref={overlayRef}
className={cn(
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
showCopyButton ? 'pr-14' : 'pr-3'
showCopyButton ? 'pr-14' : 'pr-3',
(isPreview || disabled) && 'opacity-50'
)}
>
<div className='min-w-fit whitespace-pre'>{formattedText}</div>

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { Slider } from '@/components/emcn/components/slider/slider'
import { cn } from '@/lib/core/utils/cn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface SliderInputProps {
@@ -58,15 +59,17 @@ export function SliderInput({
const percentage = ((normalizedValue - min) / (max - min)) * 100
const isDisabled = isPreview || disabled
return (
<div className='relative pt-2 pb-[22px]'>
<div className={cn('relative pt-2 pb-[22px]', isDisabled && 'opacity-50')}>
<Slider
value={[normalizedValue]}
min={min}
max={max}
step={integer ? 1 : step}
onValueChange={handleValueChange}
disabled={isPreview || disabled}
disabled={isDisabled}
/>
<div
className='absolute top-6 text-muted-foreground text-sm'

View File

@@ -949,7 +949,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</div>
)}
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
{!data.isPreview && (
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
)}
{shouldShowDefaultHandles && <Connections blockId={id} />}

View File

@@ -55,9 +55,11 @@ const WorkflowEdgeComponent = ({
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
const edgeRunStatus = lastRunEdges.get(id)
const previewExecutionStatus = (
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
)?.executionStatus
const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id)
// Memoize diff status calculation to avoid recomputing on every render
const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
if (data?.isDeleted) return 'deleted'
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
@@ -84,21 +86,39 @@ const WorkflowEdgeComponent = ({
targetHandle,
])
// Memoize edge style to prevent object recreation
const edgeStyle = useMemo(() => {
let color = 'var(--workflow-edge)'
if (edgeDiffStatus === 'deleted') color = 'var(--text-error)'
else if (isErrorEdge) color = 'var(--text-error)'
else if (edgeDiffStatus === 'new') color = 'var(--brand-tertiary)'
else if (edgeRunStatus === 'success') color = 'var(--border-success)'
else if (edgeRunStatus === 'error') color = 'var(--text-error)'
let opacity = 1
if (edgeDiffStatus === 'deleted') {
color = 'var(--text-error)'
opacity = 0.7
} else if (isErrorEdge) {
color = 'var(--text-error)'
} else if (edgeDiffStatus === 'new') {
color = 'var(--brand-tertiary)'
} else if (edgeRunStatus === 'success') {
color = 'var(--border-success)'
} else if (edgeRunStatus === 'error') {
color = 'var(--text-error)'
}
if (isSelected) {
opacity = 0.5
}
return {
...(style ?? {}),
strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2,
strokeWidth: edgeDiffStatus
? 3
: edgeRunStatus === 'success' || edgeRunStatus === 'error'
? 2.5
: isSelected
? 2.5
: 2,
stroke: color,
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1,
opacity,
}
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
@@ -137,7 +157,6 @@ const WorkflowEdgeComponent = ({
e.stopPropagation()
if (data?.onDelete) {
// Pass this specific edge's ID to the delete function
data.onDelete(id)
}
}}

View File

@@ -1337,6 +1337,11 @@ const WorkflowContent = React.memo(() => {
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
targetParentId: null,
})
addBlock(
id,
type,
@@ -1349,7 +1354,7 @@ const WorkflowContent = React.memo(() => {
},
undefined,
undefined,
undefined
autoConnectEdge
)
return
@@ -1368,6 +1373,12 @@ const WorkflowContent = React.memo(() => {
const baseName = defaultTriggerName || blockConfig.name
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
enableTriggerMode,
targetParentId: null,
})
addBlock(
id,
type,
@@ -1376,7 +1387,7 @@ const WorkflowContent = React.memo(() => {
undefined,
undefined,
undefined,
undefined,
autoConnectEdge,
enableTriggerMode
)
}
@@ -1395,6 +1406,7 @@ const WorkflowContent = React.memo(() => {
addBlock,
effectivePermissions.canEdit,
checkTriggerConstraints,
tryCreateAutoConnectEdge,
])
/**

View File

@@ -0,0 +1,680 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { ChevronDown as ChevronDownIcon, X } from 'lucide-react'
import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { getBlock } from '@/blocks'
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { navigatePath } from '@/executor/variables/resolvers/reference'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Evaluate whether a subblock's condition is met based on current values.
*/
function evaluateCondition(
condition: SubBlockConfig['condition'],
subBlockValues: Record<string, { value: unknown } | unknown>
): boolean {
if (!condition) return true
const actualCondition = typeof condition === 'function' ? condition() : condition
const fieldValueObj = subBlockValues[actualCondition.field]
const fieldValue =
fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
? (fieldValueObj as { value: unknown }).value
: fieldValueObj
const conditionValues = Array.isArray(actualCondition.value)
? actualCondition.value
: [actualCondition.value]
let isMatch = conditionValues.some((v) => v === fieldValue)
if (actualCondition.not) {
isMatch = !isMatch
}
if (actualCondition.and && isMatch) {
const andFieldValueObj = subBlockValues[actualCondition.and.field]
const andFieldValue =
andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
? (andFieldValueObj as { value: unknown }).value
: andFieldValueObj
const andConditionValues = Array.isArray(actualCondition.and.value)
? actualCondition.and.value
: [actualCondition.and.value]
let andMatch = andConditionValues.some((v) => v === andFieldValue)
if (actualCondition.and.not) {
andMatch = !andMatch
}
isMatch = isMatch && andMatch
}
return isMatch
}
/**
* Format a value for display as JSON string
*/
function formatValueAsJson(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '—'
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
return String(value)
}
interface ResolvedConnection {
blockId: string
blockName: string
blockType: string
fields: Array<{ path: string; value: string; tag: string }>
}
/**
* Extract all variable references from nested subblock values
*/
function extractAllReferencesFromSubBlocks(subBlockValues: Record<string, unknown>): string[] {
const refs = new Set<string>()
const processValue = (value: unknown) => {
if (typeof value === 'string') {
const extracted = extractReferencePrefixes(value)
extracted.forEach((ref) => refs.add(ref.raw))
} else if (Array.isArray(value)) {
value.forEach(processValue)
} else if (value && typeof value === 'object') {
if ('value' in value) {
processValue((value as { value: unknown }).value)
} else {
Object.values(value).forEach(processValue)
}
}
}
Object.values(subBlockValues).forEach(processValue)
return Array.from(refs)
}
/**
* Format a value for inline display (single line, truncated)
*/
function formatInlineValue(value: unknown): string {
if (value === null || value === undefined) return 'null'
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
return String(value)
}
interface ExecutionDataSectionProps {
title: string
data: unknown
isError?: boolean
}
/**
* Collapsible section for execution data (input/output)
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
*/
function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const jsonString = useMemo(() => {
if (!data) return ''
return formatValueAsJson(data)
}, [data])
const isEmpty = jsonString === '—' || jsonString === ''
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsExpanded(!isExpanded)
}
}}
role='button'
tabIndex={0}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${title.toLowerCase()}`}
>
<span
className={cn(
'font-medium text-[12px] transition-colors',
isError
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{title}
</span>
<ChevronDown
className={cn(
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
)}
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</div>
{isExpanded && (
<>
{isEmpty ? (
<div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
<span className='text-[12px] text-[var(--text-tertiary)]'>No data</span>
</div>
) : (
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
/>
)}
</>
)}
</div>
)
}
/**
* Section showing resolved variable references - styled like the connections section in editor
*/
function ResolvedConnectionsSection({ connections }: { connections: ResolvedConnection[] }) {
const [isCollapsed, setIsCollapsed] = useState(false)
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set())
useEffect(() => {
setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
}, [connections])
if (connections.length === 0) return null
const toggleBlock = (blockId: string) => {
setExpandedBlocks((prev) => {
const next = new Set(prev)
if (next.has(blockId)) {
next.delete(blockId)
} else {
next.add(blockId)
}
return next
})
}
return (
<div className='flex flex-shrink-0 flex-col border-[var(--border)] border-t'>
{/* Header with Chevron */}
<div
className='flex flex-shrink-0 cursor-pointer items-center gap-[8px] px-[10px] pt-[5px] pb-[5px]'
onClick={() => setIsCollapsed(!isCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsCollapsed(!isCollapsed)
}
}}
role='button'
tabIndex={0}
aria-label={isCollapsed ? 'Expand connections' : 'Collapse connections'}
>
<ChevronDownIcon
className={cn('h-[14px] w-[14px] transition-transform', !isCollapsed && 'rotate-180')}
/>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>Connections</div>
</div>
{/* Content - styled like ConnectionBlocks */}
{!isCollapsed && (
<div className='space-y-[2px] px-[6px] pb-[8px]'>
{connections.map((connection) => {
const blockConfig = getBlock(connection.blockType)
const Icon = blockConfig?.icon
const bgColor = blockConfig?.bgColor || '#6B7280'
const isExpanded = expandedBlocks.has(connection.blockId)
const hasFields = connection.fields.length > 0
return (
<div key={connection.blockId} className='mb-[2px] last:mb-0'>
{/* Block header - styled like ConnectionItem */}
<div
className={cn(
'group flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
hasFields && 'cursor-pointer'
)}
onClick={() => hasFields && toggleBlock(connection.blockId)}
>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{Icon && (
<Icon
className={cn(
'text-white transition-transform duration-200',
hasFields && 'group-hover:scale-110',
'!h-[9px] !w-[9px]'
)}
/>
)}
</div>
<span
className={cn(
'truncate font-medium',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)}
>
{connection.blockName}
</span>
{hasFields && (
<ChevronDownIcon
className={cn(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)}
/>
)}
</div>
{/* Fields - styled like FieldItem but showing resolved values */}
{isExpanded && hasFields && (
<div className='relative mt-[2px] ml-[12px] space-y-[2px] pl-[10px]'>
<div className='pointer-events-none absolute top-[4px] bottom-[4px] left-0 w-px bg-[var(--border)]' />
{connection.fields.map((field) => (
<div
key={field.tag}
className='group flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]'
>
<span
className={cn(
'flex-shrink-0 font-medium',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)}
>
{field.path}
</span>
<span className='min-w-0 flex-1 truncate text-[var(--text-tertiary)]'>
{field.value}
</span>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
/**
* Icon component for rendering block icons
*/
function IconComponent({
icon: Icon,
className,
}: {
icon: BlockIcon | undefined
className?: string
}) {
if (!Icon) return null
return <Icon className={className} />
}
interface ExecutionData {
input?: unknown
output?: unknown
status?: string
durationMs?: number
}
interface BlockDetailsSidebarProps {
block: BlockState
executionData?: ExecutionData
/** All block execution data for resolving variable references */
allBlockExecutions?: Record<string, ExecutionData>
/** All workflow blocks for mapping block names to IDs */
workflowBlocks?: Record<string, BlockState>
/** When true, shows "Not Executed" badge if no executionData is provided */
isExecutionMode?: boolean
/** Optional close handler - if not provided, no close button is shown */
onClose?: () => void
}
/**
* Format duration for display
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Readonly sidebar panel showing block configuration using SubBlock components.
*/
function BlockDetailsSidebarContent({
block,
executionData,
allBlockExecutions,
workflowBlocks,
isExecutionMode = false,
onClose,
}: BlockDetailsSidebarProps) {
const blockConfig = getBlock(block.type) as BlockConfig | undefined
const subBlockValues = block.subBlocks || {}
const blockNameToId = useMemo(() => {
const map = new Map<string, string>()
if (workflowBlocks) {
for (const [blockId, blockData] of Object.entries(workflowBlocks)) {
if (blockData.name) {
map.set(normalizeName(blockData.name), blockId)
}
}
}
return map
}, [workflowBlocks])
const resolveReference = useMemo(() => {
return (reference: string): unknown => {
if (!allBlockExecutions || !workflowBlocks) return undefined
if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
const inner = reference.slice(1, -1) // Remove < and >
const parts = inner.split('.')
if (parts.length < 1) return undefined
const [blockName, ...pathParts] = parts
const normalizedBlockName = normalizeName(blockName)
const blockId = blockNameToId.get(normalizedBlockName)
if (!blockId) return undefined
const blockExecution = allBlockExecutions[blockId]
if (!blockExecution?.output) return undefined
if (pathParts.length === 0) {
return blockExecution.output
}
return navigatePath(blockExecution.output, pathParts)
}
}, [allBlockExecutions, workflowBlocks, blockNameToId])
// Group resolved variables by source block for display
const resolvedConnections = useMemo((): ResolvedConnection[] => {
if (!allBlockExecutions || !workflowBlocks) return []
const allRefs = extractAllReferencesFromSubBlocks(subBlockValues)
const seen = new Set<string>()
const blockMap = new Map<string, ResolvedConnection>()
for (const ref of allRefs) {
if (seen.has(ref)) continue
// Parse reference: <blockName.path.to.value>
const inner = ref.slice(1, -1)
const parts = inner.split('.')
if (parts.length < 1) continue
const [blockName, ...pathParts] = parts
const normalizedBlockName = normalizeName(blockName)
const blockId = blockNameToId.get(normalizedBlockName)
if (!blockId) continue
const sourceBlock = workflowBlocks[blockId]
if (!sourceBlock) continue
const resolvedValue = resolveReference(ref)
if (resolvedValue === undefined) continue
seen.add(ref)
// Get or create block entry
if (!blockMap.has(blockId)) {
blockMap.set(blockId, {
blockId,
blockName: sourceBlock.name || blockName,
blockType: sourceBlock.type,
fields: [],
})
}
const connection = blockMap.get(blockId)!
connection.fields.push({
path: pathParts.join('.') || 'output',
value: formatInlineValue(resolvedValue),
tag: ref,
})
}
return Array.from(blockMap.values())
}, [subBlockValues, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference])
if (!blockConfig) {
return (
<div className='flex h-full w-80 flex-col overflow-hidden rounded-r-[8px] border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='flex items-center gap-[8px] bg-[var(--surface-4)] px-[12px] py-[8px]'>
<div className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px] bg-[var(--surface-3)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
{block.name || 'Unknown Block'}
</span>
</div>
<div className='p-[12px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>Block configuration not found.</p>
</div>
</div>
)
}
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
if (subBlock.mode === 'trigger') return false
if (subBlock.condition) {
return evaluateCondition(subBlock.condition, subBlockValues)
}
return true
})
const statusVariant =
executionData?.status === 'error'
? 'red'
: executionData?.status === 'success'
? 'green'
: 'gray'
return (
<div className='flex h-full w-80 flex-col overflow-hidden rounded-r-[8px] border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like editor */}
<div className='flex flex-shrink-0 items-center gap-[8px] bg-[var(--surface-4)] px-[12px] py-[8px]'>
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ backgroundColor: blockConfig.bgColor }}
>
<IconComponent
icon={blockConfig.icon}
className='h-[12px] w-[12px] text-[var(--white)]'
/>
</div>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{block.name || blockConfig.name}
</span>
{block.enabled === false && (
<Badge variant='red' size='sm'>
Disabled
</Badge>
)}
{onClose && (
<Button variant='ghost' className='!p-[4px] flex-shrink-0' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
)}
</div>
{/* Scrollable content */}
<div className='flex-1 overflow-y-auto'>
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
{isExecutionMode && !executionData && (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div className='flex items-center justify-between'>
<Badge variant='gray-secondary' size='sm' dot>
Not Executed
</Badge>
</div>
</div>
)}
{/* Execution Input/Output (if provided) */}
{executionData &&
(executionData.input !== undefined || executionData.output !== undefined) ? (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{/* Execution Status & Duration Header */}
{(executionData.status || executionData.durationMs !== undefined) && (
<div className='flex items-center justify-between'>
{executionData.status && (
<Badge variant={statusVariant} size='sm' dot>
<span className='capitalize'>{executionData.status}</span>
</Badge>
)}
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs)}
</span>
)}
</div>
)}
{/* Divider between Status/Duration and Input/Output */}
{(executionData.status || executionData.durationMs !== undefined) &&
(executionData.input !== undefined || executionData.output !== undefined) && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Input Section */}
{executionData.input !== undefined && (
<ExecutionDataSection title='Input' data={executionData.input} />
)}
{/* Divider between Input and Output */}
{executionData.input !== undefined && executionData.output !== undefined && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Output Section */}
{executionData.output !== undefined && (
<ExecutionDataSection
title={executionData.status === 'error' ? 'Error' : 'Output'}
data={executionData.output}
isError={executionData.status === 'error'}
/>
)}
</div>
) : null}
{/* Subblock Values - Using SubBlock components in preview mode */}
<div className='readonly-preview px-[8px] py-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
{visibleSubBlocks.length > 0 ? (
<div className='flex flex-col'>
{visibleSubBlocks.map((subBlockConfig, index) => (
<div key={subBlockConfig.id} className='subblock-row'>
<SubBlock
blockId={block.id}
config={subBlockConfig}
isPreview={true}
subBlockValues={subBlockValues}
disabled={true}
/>
{index < visibleSubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>
))}
</div>
) : (
<div className='py-[16px] text-center'>
<p className='text-[13px] text-[var(--text-secondary)]'>
No configurable fields for this block.
</p>
</div>
)}
</div>
</div>
{/* Resolved Variables Section - Pinned at bottom, outside scrollable area */}
{resolvedConnections.length > 0 && (
<ResolvedConnectionsSection connections={resolvedConnections} />
)}
</div>
)
}
/**
* Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
*/
export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
return (
<ReactFlowProvider>
<BlockDetailsSidebarContent {...props} />
</ReactFlowProvider>
)
}

View File

@@ -3,7 +3,7 @@
import { memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlock } from '@/blocks/registry'
import { getBlock } from '@/blocks'
interface WorkflowPreviewBlockData {
type: string
@@ -29,10 +29,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
const IconComponent = blockConfig.icon
// Hide input handle for triggers, starters, or blocks in trigger mode
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
// Get visible subblocks from config (no fetching, just config structure)
const visibleSubBlocks = useMemo(() => {
if (!blockConfig.subBlocks) return []
@@ -48,7 +46,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
const hasSubBlocks = visibleSubBlocks.length > 0
const showErrorRow = !isStarterOrTrigger
// Handle styles based on orientation
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'

View File

@@ -26,11 +26,9 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
// Handle IDs matching the actual subflow component
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
// Handle styles matching the workflow-block component
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =

View File

@@ -0,0 +1,2 @@
export { BlockDetailsSidebar } from './components/block-details-sidebar'
export { WorkflowPreview } from './preview'

View File

@@ -18,13 +18,16 @@ import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
import { getBlock } from '@/blocks'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview')
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewProps {
workflowState: WorkflowState
showSubBlocks?: boolean
@@ -40,6 +43,8 @@ interface WorkflowPreviewProps {
lightweight?: boolean
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string }>
}
/**
@@ -105,10 +110,9 @@ export function WorkflowPreview({
onNodeClick,
lightweight = false,
cursorStyle = 'grab',
executedBlocks,
}: WorkflowPreviewProps) {
// Use lightweight node types for better performance in template cards
const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
// Check if the workflow state is valid
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
@@ -178,9 +182,7 @@ export function WorkflowPreview({
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
// Lightweight mode: create minimal node data for performance
if (lightweight) {
// Handle loops and parallels as subflow nodes
if (block.type === 'loop' || block.type === 'parallel') {
nodeArray.push({
id: blockId,
@@ -197,7 +199,6 @@ export function WorkflowPreview({
return
}
// Regular blocks
nodeArray.push({
id: blockId,
type: 'workflowBlock',
@@ -214,10 +215,9 @@ export function WorkflowPreview({
return
}
// Full mode: create detailed node data for interactive previews
if (block.type === 'loop') {
nodeArray.push({
id: block.id,
id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
@@ -238,7 +238,7 @@ export function WorkflowPreview({
if (block.type === 'parallel') {
nodeArray.push({
id: block.id,
id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
@@ -265,11 +265,31 @@ export function WorkflowPreview({
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
executionStatus = 'error'
} else if (blockExecution.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: nodeType,
position: absolutePosition,
draggable: false,
className:
executionStatus && executionStatus !== 'not-executed'
? `execution-${executionStatus}`
: undefined,
data: {
type: block.type,
config: blockConfig,
@@ -278,43 +298,9 @@ export function WorkflowPreview({
canEdit: false,
isPreview: true,
subBlockValues: block.subBlocks ?? {},
executionStatus,
},
})
if (block.type === 'loop') {
const childBlocks = Object.entries(workflowState.blocks || {}).filter(
([_, childBlock]) => childBlock.data?.parentId === blockId
)
childBlocks.forEach(([childId, childBlock]) => {
const childConfig = getBlock(childBlock.type)
if (childConfig) {
const childNodeType = childBlock.type === 'note' ? 'noteBlock' : 'workflowBlock'
nodeArray.push({
id: childId,
type: childNodeType,
position: {
x: block.position.x + 50,
y: block.position.y + (childBlock.position?.y || 100),
},
data: {
type: childBlock.type,
config: childConfig,
name: childBlock.name,
blockState: childBlock,
showSubBlocks,
isChild: true,
parentId: blockId,
canEdit: false,
isPreview: true,
},
draggable: false,
})
}
})
}
})
return nodeArray
@@ -326,21 +312,42 @@ export function WorkflowPreview({
workflowState.blocks,
isValidWorkflowState,
lightweight,
executedBlocks,
])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
return (workflowState.edges || []).map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
}))
}, [edgesStructure, workflowState.edges, isValidWorkflowState])
return (workflowState.edges || []).map((edge) => {
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const sourceExecuted = executedBlocks[edge.source]
const targetExecuted = executedBlocks[edge.target]
if (sourceExecuted && targetExecuted) {
if (targetExecuted.status === 'error') {
executionStatus = 'error'
} else if (sourceExecuted.status === 'success' && targetExecuted.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
data: executionStatus ? { executionStatus } : undefined,
}
})
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
// Handle migrated logs that don't have complete workflow state
if (!isValidWorkflowState) {
return (
<div
@@ -363,13 +370,19 @@ export function WorkflowPreview({
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', className)}
>
{cursorStyle && (
<style>{`
.preview-mode .react-flow__pane {
cursor: ${cursorStyle} !important;
}
`}</style>
)}
<style>{`
${cursorStyle ? `.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }` : ''}
/* Execution status styling for nodes */
.preview-mode .react-flow__node.execution-success {
border-radius: 8px;
box-shadow: 0 0 0 4px var(--border-success);
}
.preview-mode .react-flow__node.execution-error {
border-radius: 8px;
box-shadow: 0 0 0 4px var(--text-error);
}
`}</style>
<ReactFlow
nodes={nodes}
edges={edges}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { Crown, Eye, EyeOff } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -81,7 +81,9 @@ export function BYOK() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { data: keys = [], isLoading } = useBYOKKeys(workspaceId)
const { data, isLoading } = useBYOKKeys(workspaceId)
const keys = data?.keys ?? []
const byokEnabled = data?.byokEnabled ?? true
const upsertKey = useUpsertBYOKKey()
const deleteKey = useDeleteBYOKKey()
@@ -96,6 +98,31 @@ export function BYOK() {
return keys.find((k) => k.providerId === providerId)
}
// Show enterprise-only gate if BYOK is not enabled
if (!isLoading && !byokEnabled) {
return (
<div className='flex h-full flex-col items-center justify-center gap-[16px] py-[32px]'>
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-6)]'>
<Crown className='h-[24px] w-[24px] text-[var(--amber-9)]' />
</div>
<div className='flex flex-col items-center gap-[8px] text-center'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>Enterprise Feature</h3>
<p className='max-w-[320px] text-[13px] text-[var(--text-secondary)]'>
Bring Your Own Key (BYOK) is available exclusively on the Enterprise plan. Upgrade to
use your own API keys and eliminate the 2x cost multiplier.
</p>
</div>
<Button
variant='primary'
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
onClick={() => window.open('https://sim.ai/enterprise', '_blank')}
>
Contact Sales
</Button>
</div>
)
}
const handleSave = async () => {
if (!editingProvider || !apiKeyInput.trim()) return

View File

@@ -24,9 +24,9 @@ import { getSubscriptionStatus } from '@/lib/billing/client'
import { cn } from '@/lib/core/utils/cn'
import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import {
type CredentialSet,
useAcceptCredentialSetInvitation,

View File

@@ -480,7 +480,7 @@ export function General({ onOpenChange }: GeneralProps) {
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='auto-connect'>Auto-connect on drag</Label>
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<Switch
id='auto-connect'
checked={settings?.autoConnect ?? true}

View File

@@ -3,8 +3,8 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn'
import { getUserColor } from '@/lib/workspaces/colors'
import type { Invitation, Member, Organization } from '@/lib/workspaces/organization'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import {
useCancelInvitation,
useOrganizationMembers,

View File

@@ -53,6 +53,7 @@ import { useSettingsModalStore } from '@/stores/settings-modal/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
interface SettingsModalProps {
open: boolean
@@ -86,8 +87,8 @@ type NavigationItem = {
hideWhenBillingDisabled?: boolean
requiresTeam?: boolean
requiresEnterprise?: boolean
requiresOwner?: boolean
requiresHosted?: boolean
selfHostedOverride?: boolean
}
const sectionConfig: { key: NavigationSection; title: string }[] = [
@@ -113,6 +114,7 @@ const allNavigationItems: NavigationItem[] = [
icon: Users,
section: 'subscription',
hideWhenBillingDisabled: true,
requiresHosted: true,
requiresTeam: true,
},
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
@@ -123,7 +125,8 @@ const allNavigationItems: NavigationItem[] = [
label: 'Email Polling',
icon: Mail,
section: 'system',
requiresTeam: true,
requiresHosted: true,
selfHostedOverride: isCredentialSetsEnabled,
},
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
@@ -134,6 +137,7 @@ const allNavigationItems: NavigationItem[] = [
icon: KeySquare,
section: 'system',
requiresHosted: true,
requiresEnterprise: true,
},
{
id: 'copilot',
@@ -148,9 +152,9 @@ const allNavigationItems: NavigationItem[] = [
label: 'Single Sign-On',
icon: LogIn,
section: 'system',
requiresTeam: true,
requiresHosted: true,
requiresEnterprise: true,
requiresOwner: true,
selfHostedOverride: isSSOEnabled,
},
]
@@ -173,8 +177,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const userRole = getUserRole(activeOrganization, userEmail)
const isOwner = userRole === 'owner'
const isAdmin = userRole === 'admin'
const canManageSSO = isOwner || isAdmin
const isOrgAdminOrOwner = isOwner || isAdmin
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
@@ -192,29 +197,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
// SSO has special logic that must be checked before requiresTeam
if (item.id === 'sso') {
if (isHosted) {
return hasOrganization && hasEnterprisePlan && canManageSSO
if (item.selfHostedOverride && !isHosted) {
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
}
// For self-hosted, only show SSO tab if explicitly enabled via environment variable
if (!isSSOEnabled) return false
// Show tab if user is the SSO provider owner, or if no providers exist yet (to allow initial setup)
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
return true
}
if (item.requiresTeam) {
const isMember = userRole === 'member' || isAdmin
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
if (isMember) return true
if (isOwner && hasTeamPlan) return true
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
return false
}
if (item.requiresEnterprise && !hasEnterprisePlan) {
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
return false
}
@@ -222,24 +217,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
if (item.requiresOwner && !isOwner) {
return false
}
return true
})
}, [
hasOrganization,
hasTeamPlan,
hasEnterprisePlan,
canManageSSO,
isOrgAdminOrOwner,
isSSOProviderOwner,
isSSOEnabled,
ssoProvidersData?.providers?.length,
isOwner,
isAdmin,
userRole,
subscriptionStatus.isTeam,
subscriptionStatus.isEnterprise,
])
// Memoized callbacks to prevent infinite loops in child components

View File

@@ -4,7 +4,7 @@ import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { getUserColor } from '@/lib/workspaces/colors'
import { useSocket } from '@/app/workspace/providers/socket-provider'
interface AvatarsProps {

View File

@@ -1,5 +0,0 @@
export * from './email-tag'
export * from './permission-selector'
export * from './permissions-table'
export * from './permissions-table-skeleton'
export * from './types'

View File

@@ -0,0 +1,6 @@
export { EmailTag } from './components/email-tag'
export { PermissionSelector } from './components/permission-selector'
export { PermissionsTable } from './components/permissions-table'
export { PermissionsTableSkeleton } from './components/permissions-table-skeleton'
export type { PermissionType, UserPermissions } from './components/types'
export { InviteModal } from './invite-modal'

View File

@@ -18,9 +18,10 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/email-tag'
import { PermissionsTable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table'
import { API_ENDPOINTS } from '@/stores/constants'
import type { PermissionType, UserPermissions } from './components'
import { EmailTag, PermissionsTable } from './components'
import type { PermissionType, UserPermissions } from './components/types'
const logger = createLogger('InviteModal')

View File

@@ -17,7 +17,7 @@ import {
} from '@/components/emcn'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
const logger = createLogger('WorkspaceHeader')

View File

@@ -1,54 +0,0 @@
/**
* User color palette matching terminal.tsx RUN_ID_COLORS
* These colors are used consistently across cursors, avatars, and terminal run IDs
*/
export const USER_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#FCD34D', // Yellow
] as const
/**
* Hash a user ID to generate a consistent numeric index
*
* @param userId - The user ID to hash
* @returns A positive integer
*/
function hashUserId(userId: string): number {
return Math.abs(Array.from(userId).reduce((acc, char) => acc + char.charCodeAt(0), 0))
}
/**
* Gets a consistent color for a user based on their ID.
* The same user will always get the same color across cursors, avatars, and terminal.
*
* @param userId - The unique user identifier
* @returns A hex color string
*/
export function getUserColor(userId: string): string {
const hash = hashUserId(userId)
return USER_COLORS[hash % USER_COLORS.length]
}
/**
* Creates a stable mapping of user IDs to color indices for a list of users.
* Useful when you need to maintain consistent color assignments across renders.
*
* @param userIds - Array of user IDs to map
* @returns Map of user ID to color index
*/
export function createUserColorMap(userIds: string[]): Map<string, number> {
const colorMap = new Map<string, number>()
let colorIndex = 0
for (const userId of userIds) {
if (!colorMap.has(userId)) {
colorMap.set(userId, colorIndex++)
}
}
return colorMap
}

View File

@@ -1,237 +0,0 @@
/**
* A2A Block (v0.2.6)
*
* Enables interaction with external A2A-compatible agents.
* Supports sending messages, querying tasks, cancelling tasks, and discovering agents.
*/
import { A2AIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
export interface A2AResponse extends ToolResponse {
output: {
/** Response content from the agent (send_task) */
content?: string
/** Task ID */
taskId?: string
/** Context ID for conversation continuity */
contextId?: string
/** Task state */
state?: string
/** Structured output artifacts */
artifacts?: Array<{
name?: string
description?: string
parts: Array<{ type: string; text?: string; data?: unknown }>
}>
/** Full message history */
history?: Array<{
role: 'user' | 'agent'
parts: Array<{ type: string; text?: string }>
}>
/** Whether cancellation was successful (cancel_task) */
cancelled?: boolean
/** Agent name (get_agent_card) */
name?: string
/** Agent description (get_agent_card) */
description?: string
/** Agent URL (get_agent_card) */
url?: string
/** Agent version (get_agent_card) */
version?: string
/** Agent capabilities (get_agent_card) */
capabilities?: Record<string, boolean>
/** Agent skills (get_agent_card) */
skills?: Array<{ id: string; name: string; description?: string }>
/** Agent authentication schemes (get_agent_card) */
authentication?: { schemes: string[] }
}
}
export const A2ABlock: BlockConfig<A2AResponse> = {
type: 'a2a',
name: 'A2A',
description: 'Interact with external A2A-compatible agents',
longDescription:
'Use the A2A (Agent-to-Agent) protocol to interact with external AI agents. ' +
'Send messages, query task status, cancel tasks, or discover agent capabilities. ' +
'Compatible with any A2A-compliant agent including LangGraph, Google ADK, and other Sim Studio workflows.',
docsLink: 'https://docs.sim.ai/blocks/a2a',
category: 'tools',
bgColor: '#4151B5',
icon: A2AIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Send Message', id: 'send_task' },
{ label: 'Get Task', id: 'get_task' },
{ label: 'Cancel Task', id: 'cancel_task' },
{ label: 'Get Agent Card', id: 'get_agent_card' },
],
defaultValue: 'send_task',
},
{
id: 'agentUrl',
title: 'Agent URL',
type: 'short-input',
placeholder: 'https://api.example.com/a2a/serve/agent-id',
required: true,
description: 'The A2A endpoint URL',
},
{
id: 'message',
title: 'Message',
type: 'long-input',
placeholder: 'Enter your message to the agent...',
description: 'The message to send to the agent',
condition: { field: 'operation', value: 'send_task' },
required: { field: 'operation', value: 'send_task' },
},
{
id: 'taskId',
title: 'Task ID',
type: 'short-input',
placeholder: 'Task ID',
description: 'Task ID to query, cancel, or continue',
condition: { field: 'operation', value: ['send_task', 'get_task', 'cancel_task'] },
required: { field: 'operation', value: ['get_task', 'cancel_task'] },
},
{
id: 'contextId',
title: 'Context ID',
type: 'short-input',
placeholder: 'Optional - for multi-turn conversations',
description: 'Context ID for conversation continuity across tasks',
condition: { field: 'operation', value: 'send_task' },
},
{
id: 'historyLength',
title: 'History Length',
type: 'short-input',
placeholder: 'Number of messages to include',
description: 'Number of history messages to include in the response',
condition: { field: 'operation', value: 'get_task' },
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
password: true,
placeholder: 'API key for the remote agent',
description: 'Authentication key for the A2A agent',
},
],
tools: {
access: ['a2a_send_task', 'a2a_get_task', 'a2a_cancel_task', 'a2a_get_agent_card'],
config: {
tool: (params: Record<string, unknown>) => {
const operation = params.operation as string
switch (operation) {
case 'get_task':
return 'a2a_get_task'
case 'cancel_task':
return 'a2a_cancel_task'
case 'get_agent_card':
return 'a2a_get_agent_card'
default:
return 'a2a_send_task'
}
},
},
},
inputs: {
operation: {
type: 'string',
description: 'A2A operation to perform',
},
agentUrl: {
type: 'string',
description: 'A2A endpoint URL',
},
message: {
type: 'string',
description: 'Message to send to the agent',
},
taskId: {
type: 'string',
description: 'Task ID to query, cancel, or continue',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
historyLength: {
type: 'number',
description: 'Number of history messages to include',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
outputs: {
// Send task outputs
content: {
type: 'string',
description: 'The text response from the agent',
},
taskId: {
type: 'string',
description: 'Task ID for follow-up interactions',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
state: {
type: 'string',
description: 'Task state (completed, failed, etc.)',
},
artifacts: {
type: 'array',
description: 'Structured output artifacts from the agent',
},
history: {
type: 'array',
description: 'Full message history of the conversation',
},
// Cancel task output
cancelled: {
type: 'boolean',
description: 'Whether the task was successfully cancelled',
},
// Get agent card outputs
name: {
type: 'string',
description: 'Agent name',
},
description: {
type: 'string',
description: 'Agent description',
},
url: {
type: 'string',
description: 'Agent endpoint URL',
},
version: {
type: 'string',
description: 'Agent version',
},
capabilities: {
type: 'json',
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
},
skills: {
type: 'array',
description: 'Skills the agent can perform',
},
authentication: {
type: 'json',
description: 'Supported authentication schemes',
},
},
}

View File

@@ -77,7 +77,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
// Project Update Operations
{ label: 'Create Project Update', id: 'linear_create_project_update' },
{ label: 'List Project Updates', id: 'linear_list_project_updates' },
{ label: 'Create Project Link', id: 'linear_create_project_link' },
// Notification Operations
{ label: 'List Notifications', id: 'linear_list_notifications' },
{ label: 'Update Notification', id: 'linear_update_notification' },
@@ -227,6 +226,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
'linear_update_project',
'linear_archive_project',
'linear_delete_project',
'linear_create_project_update',
'linear_list_project_updates',
],
},
@@ -239,6 +239,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
'linear_update_project',
'linear_archive_project',
'linear_delete_project',
'linear_create_project_update',
'linear_list_project_updates',
'linear_list_project_labels',
],
@@ -261,7 +262,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
'linear_delete_project',
'linear_create_project_update',
'linear_list_project_updates',
'linear_create_project_link',
],
},
condition: {
@@ -275,7 +275,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
'linear_delete_project',
'linear_create_project_update',
'linear_list_project_updates',
'linear_create_project_link',
'linear_list_project_labels',
],
},
@@ -625,7 +624,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
required: true,
condition: {
field: 'operation',
value: ['linear_create_attachment', 'linear_create_project_link'],
value: ['linear_create_attachment'],
},
},
// Attachment title
@@ -1221,6 +1220,36 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
value: ['linear_create_project_status'],
},
},
{
id: 'projectStatusType',
title: 'Status Type',
type: 'dropdown',
options: [
{ label: 'Backlog', id: 'backlog' },
{ label: 'Planned', id: 'planned' },
{ label: 'Started', id: 'started' },
{ label: 'Paused', id: 'paused' },
{ label: 'Completed', id: 'completed' },
{ label: 'Canceled', id: 'canceled' },
],
value: () => 'started',
required: true,
condition: {
field: 'operation',
value: ['linear_create_project_status'],
},
},
{
id: 'projectStatusPosition',
title: 'Position',
type: 'short-input',
placeholder: 'Enter position (e.g. 0, 1, 2...)',
required: true,
condition: {
field: 'operation',
value: ['linear_create_project_status'],
},
},
{
id: 'projectStatusId',
title: 'Status ID',
@@ -1326,7 +1355,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
'linear_list_favorites',
'linear_create_project_update',
'linear_list_project_updates',
'linear_create_project_link',
'linear_list_notifications',
'linear_update_notification',
'linear_create_customer',
@@ -1772,17 +1800,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
projectId: effectiveProjectId,
}
case 'linear_create_project_link':
if (!effectiveProjectId || !params.url?.trim()) {
throw new Error('Project ID and URL are required.')
}
return {
...baseParams,
projectId: effectiveProjectId,
url: params.url.trim(),
label: params.name,
}
case 'linear_list_notifications':
return baseParams
@@ -2033,22 +2050,22 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}
case 'linear_add_label_to_project':
if (!effectiveProjectId || !params.projectLabelId?.trim()) {
if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
throw new Error('Project ID and label ID are required.')
}
return {
...baseParams,
projectId: effectiveProjectId,
projectId: params.projectIdForMilestone.trim(),
labelId: params.projectLabelId.trim(),
}
case 'linear_remove_label_from_project':
if (!effectiveProjectId || !params.projectLabelId?.trim()) {
if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
throw new Error('Project ID and label ID are required.')
}
return {
...baseParams,
projectId: effectiveProjectId,
projectId: params.projectIdForMilestone.trim(),
labelId: params.projectLabelId.trim(),
}
@@ -2097,13 +2114,20 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
// Project Status Operations
case 'linear_create_project_status':
if (!params.projectStatusName?.trim() || !params.statusColor?.trim()) {
throw new Error('Project status name and color are required.')
if (
!params.projectStatusName?.trim() ||
!params.projectStatusType?.trim() ||
!params.statusColor?.trim() ||
!params.projectStatusPosition?.trim()
) {
throw new Error('Project status name, type, color, and position are required.')
}
return {
...baseParams,
name: params.projectStatusName.trim(),
type: params.projectStatusType.trim(),
color: params.statusColor.trim(),
position: Number.parseFloat(params.projectStatusPosition.trim()),
description: params.projectStatusDescription?.trim() || undefined,
indefinite: params.projectStatusIndefinite === 'true',
}
@@ -2270,7 +2294,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
// Project update outputs
update: { type: 'json', description: 'Project update data' },
updates: { type: 'json', description: 'Project updates list' },
link: { type: 'json', description: 'Project link data' },
// Notification outputs
notification: { type: 'json', description: 'Notification data' },
notifications: { type: 'json', description: 'Notifications list' },

View File

@@ -1,4 +1,3 @@
import { A2ABlock } from '@/blocks/blocks/a2a'
import { AgentBlock } from '@/blocks/blocks/agent'
import { AhrefsBlock } from '@/blocks/blocks/ahrefs'
import { AirtableBlock } from '@/blocks/blocks/airtable'
@@ -148,7 +147,6 @@ import { SQSBlock } from './blocks/sqs'
// Registry of all available blocks, alphabetically sorted
export const registry: Record<string, BlockConfig> = {
a2a: A2ABlock,
agent: AgentBlock,
ahrefs: AhrefsBlock,
airtable: AirtableBlock,

View File

@@ -23,7 +23,7 @@ import { cn } from '@/lib/core/utils/cn'
* ```
*/
const checkboxVariants = cva(
'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
{
variants: {
size: {

View File

@@ -467,7 +467,12 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
{...inputProps}
/>
{(overlayContent || SelectedIcon) && (
<div className='pointer-events-none absolute top-0 right-[42px] bottom-0 left-0 flex items-center bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
<div
className={cn(
'pointer-events-none absolute top-0 right-[42px] bottom-0 left-0 flex items-center bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
disabled && 'opacity-50'
)}
>
{overlayContent ? (
overlayContent
) : (
@@ -505,6 +510,7 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
className={cn(
comboboxVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleToggle}

View File

@@ -844,6 +844,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
className={cn(
datePickerVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleTriggerClick}

View File

@@ -16,12 +16,13 @@ export interface SliderProps extends React.ComponentPropsWithoutRef<typeof Slide
* ```
*/
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
({ className, ...props }, ref) => (
({ className, disabled, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
disabled={disabled}
className={cn(
'relative flex w-full touch-none select-none items-center',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
className
)}
{...props}

View File

@@ -12,10 +12,12 @@ import { cn } from '@/lib/core/utils/cn'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
>(({ className, disabled, ...props }, ref) => (
<SwitchPrimitives.Root
disabled={disabled}
className={cn(
'peer inline-flex h-[17px] w-[30px] shrink-0 cursor-pointer items-center rounded-[20px] transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
'peer inline-flex h-[17px] w-[30px] shrink-0 cursor-pointer items-center rounded-[20px] transition-colors focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'bg-[var(--border-1)] data-[state=checked]:bg-[var(--text-primary)]',
className
)}

View File

@@ -4061,31 +4061,6 @@ export function McpIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function A2AIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 860 860' fill='none' xmlns='http://www.w3.org/2000/svg'>
<circle cx='544' cy='307' r='27' fill='currentColor' />
<circle cx='154' cy='307' r='27' fill='currentColor' />
<circle cx='706' cy='307' r='27' fill='currentColor' />
<circle cx='316' cy='307' r='27' fill='currentColor' />
<path
d='M336.5 191.003H162C97.6588 191.003 45.5 243.162 45.5 307.503C45.5 371.844 97.6442 424.003 161.985 424.003C206.551 424.003 256.288 424.003 296.5 424.003C487.5 424.003 374 191.005 569 191.001C613.886 191 658.966 191 698.025 191C762.366 191.001 814.5 243.16 814.5 307.501C814.5 371.843 762.34 424.003 697.998 424.003H523.5'
stroke='currentColor'
strokeWidth='48'
strokeLinecap='round'
/>
<path
d='M256 510.002C270.359 510.002 282 521.643 282 536.002C282 550.361 270.359 562.002 256 562.002H148C133.641 562.002 122 550.361 122 536.002C122 521.643 133.641 510.002 148 510.002H256ZM712 510.002C726.359 510.002 738 521.643 738 536.002C738 550.361 726.359 562.002 712 562.002H360C345.641 562.002 334 550.361 334 536.002C334 521.643 345.641 510.002 360 510.002H712Z'
fill='currentColor'
/>
<path
d='M444 628.002C458.359 628.002 470 639.643 470 654.002C470 668.361 458.359 680.002 444 680.002H100C85.6406 680.002 74 668.361 74 654.002C74 639.643 85.6406 628.002 100 628.002H444ZM548 628.002C562.359 628.002 574 639.643 574 654.002C574 668.361 562.359 680.002 548 680.002C533.641 680.002 522 668.361 522 654.002C522 639.643 533.641 628.002 548 628.002ZM760 628.002C774.359 628.002 786 639.643 786 654.002C786 668.361 774.359 680.002 760 680.002H652C637.641 680.002 626 668.361 626 654.002C626 639.643 637.641 628.002 652 628.002H760Z'
fill='currentColor'
/>
</svg>
)
}
export function WordpressIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25.925 25.925'>

View File

@@ -1,278 +0,0 @@
/**
* A2A Agents React Query Hooks
*
* Hooks for managing A2A agents in the UI.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { AgentAuthentication, AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
/**
* A2A Agent as returned from the API
*/
export interface A2AAgent {
id: string
workspaceId: string
workflowId: string
name: string
description?: string
version: string
capabilities: AgentCapabilities
skills: AgentSkill[]
authentication: AgentAuthentication
isPublished: boolean
publishedAt?: string
createdAt: string
updatedAt: string
workflowName?: string
workflowDescription?: string
isDeployed?: boolean
taskCount?: number
}
/**
* Query keys for A2A agents
*/
export const a2aAgentKeys = {
all: ['a2a-agents'] as const,
list: (workspaceId: string) => [...a2aAgentKeys.all, 'list', workspaceId] as const,
detail: (agentId: string) => [...a2aAgentKeys.all, 'detail', agentId] as const,
}
/**
* Fetch A2A agents for a workspace
*/
async function fetchA2AAgents(workspaceId: string): Promise<A2AAgent[]> {
const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`)
if (!response.ok) {
throw new Error('Failed to fetch A2A agents')
}
const data = await response.json()
return data.agents
}
/**
* Hook to list A2A agents for a workspace
*/
export function useA2AAgents(workspaceId: string) {
return useQuery({
queryKey: a2aAgentKeys.list(workspaceId),
queryFn: () => fetchA2AAgents(workspaceId),
enabled: Boolean(workspaceId),
staleTime: 60 * 1000, // 1 minute
})
}
/**
* Agent Card as returned from the agent detail endpoint
*/
export interface A2AAgentCard {
name: string
description?: string
url: string
version: string
documentationUrl?: string
provider?: {
organization: string
url?: string
}
capabilities: AgentCapabilities
skills: AgentSkill[]
authentication?: AgentAuthentication
defaultInputModes?: string[]
defaultOutputModes?: string[]
}
/**
* Fetch a single A2A agent card (discovery document)
*/
async function fetchA2AAgentCard(agentId: string): Promise<A2AAgentCard> {
const response = await fetch(`/api/a2a/agents/${agentId}`)
if (!response.ok) {
throw new Error('Failed to fetch A2A agent')
}
return response.json()
}
/**
* Hook to get a single A2A agent card (discovery document)
*/
export function useA2AAgentCard(agentId: string) {
return useQuery({
queryKey: a2aAgentKeys.detail(agentId),
queryFn: () => fetchA2AAgentCard(agentId),
enabled: Boolean(agentId),
})
}
/**
* Create A2A agent params
*/
export interface CreateA2AAgentParams {
workspaceId: string
workflowId: string
name?: string
description?: string
capabilities?: AgentCapabilities
authentication?: AgentAuthentication
}
/**
* Create a new A2A agent
*/
async function createA2AAgent(params: CreateA2AAgentParams): Promise<A2AAgent> {
const response = await fetch('/api/a2a/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create A2A agent')
}
const data = await response.json()
return data.agent
}
/**
* Hook to create an A2A agent
*/
export function useCreateA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createA2AAgent,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(data.workspaceId),
})
},
})
}
/**
* Update A2A agent params
*/
export interface UpdateA2AAgentParams {
agentId: string
name?: string
description?: string
version?: string
capabilities?: AgentCapabilities
skills?: AgentSkill[]
authentication?: AgentAuthentication
isPublished?: boolean
}
/**
* Update an A2A agent
*/
async function updateA2AAgent(params: UpdateA2AAgentParams): Promise<A2AAgent> {
const { agentId, ...body } = params
const response = await fetch(`/api/a2a/agents/${agentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update A2A agent')
}
const data = await response.json()
return data.agent
}
/**
* Hook to update an A2A agent
*/
export function useUpdateA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateA2AAgent,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.detail(data.id),
})
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(data.workspaceId),
})
},
})
}
/**
* Delete an A2A agent
*/
async function deleteA2AAgent(params: { agentId: string; workspaceId: string }): Promise<void> {
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete A2A agent')
}
}
/**
* Hook to delete an A2A agent
*/
export function useDeleteA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteA2AAgent,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(variables.workspaceId),
})
},
})
}
/**
* Publish/unpublish agent params
*/
export interface PublishA2AAgentParams {
agentId: string
workspaceId: string
action: 'publish' | 'unpublish' | 'refresh'
}
/**
* Publish or unpublish an A2A agent
*/
async function publishA2AAgent(params: PublishA2AAgentParams): Promise<{
isPublished?: boolean
skills?: AgentSkill[]
}> {
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: params.action }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update A2A agent')
}
return response.json()
}
/**
* Hook to publish/unpublish an A2A agent
*/
export function usePublishA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: publishA2AAgent,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.detail(variables.agentId),
})
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(variables.workspaceId),
})
},
})
}

View File

@@ -15,18 +15,26 @@ export interface BYOKKey {
updatedAt: string
}
export interface BYOKKeysResponse {
keys: BYOKKey[]
byokEnabled: boolean
}
export const byokKeysKeys = {
all: ['byok-keys'] as const,
workspace: (workspaceId: string) => [...byokKeysKeys.all, 'workspace', workspaceId] as const,
}
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKey[]> {
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKeysResponse> {
const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId))
if (!response.ok) {
throw new Error(`Failed to load BYOK keys: ${response.statusText}`)
}
const { keys } = await response.json()
return keys
const data = await response.json()
return {
keys: data.keys ?? [],
byokEnabled: data.byokEnabled ?? true,
}
}
export function useBYOKKeys(workspaceId: string) {
@@ -36,6 +44,7 @@ export function useBYOKKeys(workspaceId: string) {
enabled: !!workspaceId,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
select: (data) => data,
})
}

View File

@@ -12,6 +12,9 @@ export const logKeys = {
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
dashboard: (workspaceId: string | undefined, filters: Record<string, unknown>) =>
[...logKeys.all, 'dashboard', workspaceId ?? '', filters] as const,
executionSnapshots: () => [...logKeys.all, 'executionSnapshot'] as const,
executionSnapshot: (executionId: string | undefined) =>
[...logKeys.executionSnapshots(), executionId ?? ''] as const,
}
interface LogFilters {
@@ -196,3 +199,45 @@ export function useDashboardLogs(
placeholderData: keepPreviousData,
})
}
export interface ExecutionSnapshotData {
executionId: string
workflowId: string
workflowState: Record<string, unknown>
executionMetadata: {
trigger: string
startedAt: string
endedAt?: string
totalDurationMs?: number
cost: {
total: number | null
input: number | null
output: number | null
}
totalTokens: number | null
}
}
async function fetchExecutionSnapshot(executionId: string): Promise<ExecutionSnapshotData> {
const response = await fetch(`/api/logs/execution/${executionId}`)
if (!response.ok) {
throw new Error(`Failed to fetch execution snapshot: ${response.statusText}`)
}
const data = await response.json()
if (!data) {
throw new Error('No execution snapshot data returned')
}
return data
}
export function useExecutionSnapshot(executionId: string | undefined) {
return useQuery({
queryKey: logKeys.executionSnapshot(executionId),
queryFn: () => fetchExecutionSnapshot(executionId as string),
enabled: Boolean(executionId),
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
})
}

View File

@@ -13,6 +13,7 @@ import {
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowQueries')
@@ -20,6 +21,9 @@ export const workflowKeys = {
all: ['workflows'] as const,
lists: () => [...workflowKeys.all, 'list'] as const,
list: (workspaceId: string | undefined) => [...workflowKeys.lists(), workspaceId ?? ''] as const,
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
}
function mapWorkflow(workflow: any): WorkflowMetadata {
@@ -339,3 +343,60 @@ export function useDuplicateWorkflowMutation() {
},
})
}
interface DeploymentVersionStateResponse {
deployedState: WorkflowState
}
async function fetchDeploymentVersionState(
workflowId: string,
version: number
): Promise<WorkflowState> {
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
if (!response.ok) {
throw new Error(`Failed to fetch deployment version: ${response.statusText}`)
}
const data: DeploymentVersionStateResponse = await response.json()
if (!data.deployedState) {
throw new Error('No deployed state returned')
}
return data.deployedState
}
/**
* Hook for fetching the workflow state of a specific deployment version.
* Used in the deploy modal to preview historical versions.
*/
export function useDeploymentVersionState(workflowId: string | null, version: number | null) {
return useQuery({
queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined),
queryFn: () => fetchDeploymentVersionState(workflowId as string, version as number),
enabled: Boolean(workflowId) && version !== null,
staleTime: 5 * 60 * 1000, // 5 minutes - deployment versions don't change
})
}
interface RevertToVersionVariables {
workflowId: string
version: number
}
/**
* Mutation hook for reverting (loading) a deployment version into the current workflow.
*/
export function useRevertToVersion() {
return useMutation({
mutationFn: async ({ workflowId, version }: RevertToVersionVariables): Promise<void> => {
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to load deployment')
}
},
})
}

View File

@@ -1,185 +0,0 @@
/**
* A2A Agent Card Generation
*
* Generates Agent Cards from workflow metadata and configuration.
*/
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
extractInputFormatFromBlocks,
generateToolInputSchema,
} from '@/lib/mcp/workflow-tool-schema'
import type { InputFormatField } from '@/lib/workflows/types'
import {
A2A_DEFAULT_CAPABILITIES,
A2A_DEFAULT_INPUT_MODES,
A2A_DEFAULT_OUTPUT_MODES,
} from './constants'
import type {
AgentAuthentication,
AgentCapabilities,
AgentCard,
AgentSkill,
JSONSchema,
} from './types'
import { buildA2AEndpointUrl, sanitizeAgentName } from './utils'
interface WorkflowData {
id: string
name: string
description?: string | null
}
interface AgentData {
id: string
name: string
description?: string | null
version: string
capabilities?: AgentCapabilities
skills?: AgentSkill[]
authentication?: AgentAuthentication
}
/**
* Generate an Agent Card from agent and workflow data
*/
export function generateAgentCard(agent: AgentData, workflow: WorkflowData): AgentCard {
const baseUrl = getBaseUrl()
return {
name: agent.name,
description: agent.description || workflow.description || undefined,
url: buildA2AEndpointUrl(baseUrl, agent.id),
version: agent.version || '1.0.0',
documentationUrl: `${baseUrl}/docs/a2a`,
provider: {
organization: 'Sim Studio',
url: baseUrl,
},
capabilities: {
...A2A_DEFAULT_CAPABILITIES,
...agent.capabilities,
},
skills: agent.skills || [
{
id: 'execute',
name: `Execute ${workflow.name}`,
description: workflow.description || `Execute the ${workflow.name} workflow`,
},
],
authentication: agent.authentication || {
schemes: ['bearer', 'apiKey'],
},
defaultInputModes: [...A2A_DEFAULT_INPUT_MODES],
defaultOutputModes: [...A2A_DEFAULT_OUTPUT_MODES],
}
}
/**
* Generate skills from workflow input format
*/
export function generateSkillsFromWorkflow(
workflowId: string,
workflowName: string,
workflowDescription: string | undefined | null,
blocks: Record<string, unknown>
): AgentSkill[] {
const inputFormat = extractInputFormatFromBlocks(blocks)
const skill: AgentSkill = {
id: 'execute',
name: `Execute ${workflowName}`,
description: workflowDescription || `Execute the ${workflowName} workflow`,
tags: ['workflow', 'automation'],
}
if (inputFormat && inputFormat.length > 0) {
skill.inputSchema = convertInputFormatToJSONSchema(inputFormat)
}
// Add default output schema
skill.outputSchema = {
type: 'object',
properties: {
content: {
type: 'string',
description: 'The main text output from the workflow',
},
data: {
type: 'object',
description: 'Structured data output from the workflow',
},
},
}
return [skill]
}
/**
* Convert InputFormatField array to JSON Schema
*/
export function convertInputFormatToJSONSchema(inputFormat: InputFormatField[]): JSONSchema {
const mcpSchema = generateToolInputSchema(inputFormat)
return {
type: 'object',
properties: mcpSchema.properties as Record<string, JSONSchema>,
required: mcpSchema.required,
}
}
/**
* Generate a default agent name from workflow name
*/
export function generateDefaultAgentName(workflowName: string): string {
return sanitizeAgentName(workflowName)
}
/**
* Validate agent card structure
*/
export function validateAgentCard(card: unknown): card is AgentCard {
if (!card || typeof card !== 'object') return false
const c = card as Record<string, unknown>
// Required fields
if (typeof c.name !== 'string' || !c.name) return false
if (typeof c.url !== 'string' || !c.url) return false
if (typeof c.version !== 'string' || !c.version) return false
// Capabilities must be an object
if (c.capabilities && typeof c.capabilities !== 'object') return false
// Skills must be an array
if (!Array.isArray(c.skills)) return false
return true
}
/**
* Merge agent card with updates (partial update support)
*/
export function mergeAgentCard(existing: AgentCard, updates: Partial<AgentCard>): AgentCard {
return {
...existing,
...updates,
capabilities: {
...existing.capabilities,
...updates.capabilities,
},
skills: updates.skills || existing.skills,
authentication: updates.authentication || existing.authentication,
}
}
/**
* Create agent card URL paths
*/
export function getAgentCardPaths(agentId: string) {
const baseUrl = getBaseUrl()
return {
card: `${baseUrl}/api/a2a/agents/${agentId}`,
serve: `${baseUrl}/api/a2a/serve/${agentId}`,
}
}

View File

@@ -1,69 +0,0 @@
/**
* A2A Protocol Constants (v0.2.6)
*/
/** A2A Protocol version */
export const A2A_PROTOCOL_VERSION = '0.2.6'
/** Default timeout for A2A requests (5 minutes) */
export const A2A_DEFAULT_TIMEOUT = 300000
/** Maximum message history length */
export const A2A_MAX_HISTORY_LENGTH = 100
/** Supported authentication schemes */
export const A2A_AUTH_SCHEMES = ['bearer', 'apiKey', 'oauth2', 'none'] as const
/** Task state values (v0.2.6) */
export const A2A_TASK_STATE = {
SUBMITTED: 'submitted',
WORKING: 'working',
INPUT_REQUIRED: 'input-required',
COMPLETED: 'completed',
FAILED: 'failed',
CANCELED: 'canceled',
REJECTED: 'rejected',
AUTH_REQUIRED: 'auth-required',
UNKNOWN: 'unknown',
} as const
/** Valid task state transitions */
export const A2A_VALID_TRANSITIONS: Record<string, string[]> = {
submitted: ['working', 'failed', 'canceled', 'rejected'],
working: ['completed', 'failed', 'canceled', 'input-required'],
'input-required': ['working', 'failed', 'canceled'],
'auth-required': ['working', 'failed', 'canceled'],
completed: [],
failed: [],
canceled: [],
rejected: [],
unknown: [],
}
/** JSON-RPC methods supported by A2A */
export const A2A_METHODS = {
TASKS_SEND: 'tasks/send',
TASKS_GET: 'tasks/get',
TASKS_CANCEL: 'tasks/cancel',
TASKS_SEND_SUBSCRIBE: 'tasks/sendSubscribe',
} as const
/** Well-known path for agent card discovery */
export const A2A_WELL_KNOWN_PATH = '/.well-known/agent.json'
/** Default capabilities for new agents */
export const A2A_DEFAULT_CAPABILITIES = {
streaming: true,
pushNotifications: false,
stateTransitionHistory: true,
} as const
/** Default input/output modes */
export const A2A_DEFAULT_INPUT_MODES = ['text', 'data'] as const
export const A2A_DEFAULT_OUTPUT_MODES = ['text', 'data'] as const
/** Cache settings */
export const A2A_CACHE = {
AGENT_CARD_TTL: 3600, // 1 hour
TASK_TTL: 86400, // 24 hours
} as const

View File

@@ -1,12 +0,0 @@
/**
* A2A (Agent-to-Agent) Protocol Implementation
*
* This module provides A2A protocol support for Sim Studio,
* enabling workflows to be exposed as A2A agents and allowing
* workflows to call external A2A agents.
*/
export * from './agent-card'
export * from './constants'
export * from './types'
export * from './utils'

View File

@@ -1,368 +0,0 @@
/**
* A2A (Agent-to-Agent) Protocol Types
*
* Implements the A2A protocol specification for agent interoperability.
* @see https://a2a-protocol.org/specification
*/
/**
* JSON Schema type for input/output definitions
*/
export interface JSONSchema {
type: string
properties?: Record<string, JSONSchema | JSONSchemaProperty>
required?: string[]
items?: JSONSchema
description?: string
enum?: string[]
default?: unknown
additionalProperties?: boolean | JSONSchema
}
export interface JSONSchemaProperty {
type: string
description?: string
enum?: string[]
default?: unknown
items?: JSONSchema
}
/**
* Agent Card - Discovery document for A2A agents
* Describes an agent's capabilities, skills, and how to interact with it
*/
export interface AgentCard {
/** Human-readable name of the agent */
name: string
/** Description of what the agent does */
description?: string
/** Base URL for the agent's A2A endpoint */
url: string
/** Version of the agent implementation */
version: string
/** URL to agent documentation */
documentationUrl?: string
/** Provider information */
provider?: AgentProvider
/** Agent capabilities */
capabilities: AgentCapabilities
/** Skills the agent can perform */
skills: AgentSkill[]
/** Authentication configuration */
authentication?: AgentAuthentication
/** Default input modes accepted */
defaultInputModes?: InputMode[]
/** Default output modes produced */
defaultOutputModes?: OutputMode[]
}
export interface AgentProvider {
organization: string
url?: string
}
export interface AgentCapabilities {
/** Whether the agent supports streaming responses */
streaming?: boolean
/** Whether the agent supports push notifications */
pushNotifications?: boolean
/** Whether the agent tracks state transition history */
stateTransitionHistory?: boolean
}
export interface AgentSkill {
/** Unique identifier for the skill */
id: string
/** Human-readable name */
name: string
/** Description of what the skill does */
description?: string
/** Tags for categorization */
tags?: string[]
/** JSON Schema for input parameters */
inputSchema?: JSONSchema
/** JSON Schema for output */
outputSchema?: JSONSchema
/** Example interactions */
examples?: SkillExample[]
}
export interface SkillExample {
input: TaskMessage
output: TaskMessage[]
}
export interface AgentAuthentication {
/** Supported authentication schemes */
schemes: AuthScheme[]
/** Credentials hint or reference */
credentials?: string
}
export type AuthScheme = 'bearer' | 'apiKey' | 'oauth2' | 'none'
export type InputMode = 'text' | 'file' | 'data'
export type OutputMode = 'text' | 'file' | 'data'
/**
* Task - Core unit of work in A2A protocol (v0.2.6)
*/
export interface Task {
/** Unique task identifier */
id: string
/** Server-generated context ID for contextual alignment across interactions */
contextId?: string
/** Current task status */
status: TaskStatusObject
/** Message history */
history?: TaskMessage[]
/** Structured output artifacts */
artifacts?: Artifact[]
/** Additional metadata */
metadata?: Record<string, unknown>
/** Event kind - always "task" */
kind?: 'task'
}
/**
* Task state lifecycle (v0.2.6)
*/
export type TaskState =
| 'submitted'
| 'working'
| 'input-required'
| 'completed'
| 'failed'
| 'canceled'
| 'rejected'
| 'auth-required'
| 'unknown'
/**
* Task status object (v0.2.6)
* Represents the current state and associated context of a Task
*/
export interface TaskStatusObject {
/** The current lifecycle state of the task */
state: TaskState
/** Additional status updates for the client */
message?: TaskMessage
/** ISO 8601 datetime string indicating when the status was recorded */
timestamp?: string
}
/**
* Legacy TaskStatus type for backward compatibility
* @deprecated Use TaskState instead
*/
export type TaskStatus = TaskState
/**
* Task message - A single message in a task conversation
*/
export interface TaskMessage {
/** Message role */
role: 'user' | 'agent'
/** Message content parts */
parts: MessagePart[]
/** Additional metadata */
metadata?: Record<string, unknown>
}
/**
* Message part types
*/
export type MessagePart = TextPart | FilePart | DataPart
export interface TextPart {
type: 'text'
text: string
}
export interface FilePart {
type: 'file'
file: FileContent
}
export interface FileContent {
name?: string
mimeType?: string
/** Base64 encoded content */
bytes?: string
/** URI reference to file */
uri?: string
}
export interface DataPart {
type: 'data'
data: Record<string, unknown>
}
/**
* Artifact - Structured output from an agent
*/
export interface Artifact {
/** Artifact name */
name?: string
/** Description of the artifact */
description?: string
/** Content parts */
parts: MessagePart[]
/** Index for ordering */
index: number
/** Whether to append to existing artifact */
append?: boolean
/** Whether this is the last chunk (for streaming) */
lastChunk?: boolean
}
/**
* JSON-RPC Request Parameters (v0.2.6)
*/
export interface TaskSendParams {
/** Task ID (optional for new tasks) */
id?: string
/** Context ID for contextual alignment across interactions */
contextId?: string
/** Message to send */
message: TaskMessage
/** Accepted output modes */
acceptedOutputModes?: OutputMode[]
/** Push notification configuration */
pushNotificationConfig?: PushNotificationConfig
/** Additional metadata */
metadata?: Record<string, unknown>
}
export interface TaskQueryParams {
/** Task ID to query */
id: string
/** Number of history messages to include */
historyLength?: number
}
export interface TaskCancelParams {
/** Task ID to cancel */
id: string
}
export interface PushNotificationConfig {
/** Webhook URL for notifications */
url: string
/** Authentication token */
token?: string
/** Authentication configuration */
authentication?: {
schemes: string[]
credentials?: string
}
}
/**
* Task status update event (for streaming)
*/
export interface TaskStatusUpdate {
/** Task ID */
id: string
/** Updated status */
status: TaskStatusObject
/** Final result (if completed) */
final?: boolean
}
/**
* Task artifact update event (for streaming)
*/
export interface TaskArtifactUpdate {
/** Task ID */
id: string
/** Artifact being updated */
artifact: Artifact
}
/**
* A2A Error codes (aligned with JSON-RPC)
*/
export const A2AErrorCode = {
// Standard JSON-RPC errors
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
// A2A-specific errors
TASK_NOT_FOUND: -32001,
TASK_ALREADY_COMPLETE: -32002,
AGENT_UNAVAILABLE: -32003,
SKILL_NOT_FOUND: -32004,
AUTHENTICATION_REQUIRED: -32005,
RATE_LIMITED: -32006,
} as const
export type A2AErrorCodeType = (typeof A2AErrorCode)[keyof typeof A2AErrorCode]
/**
* A2A Error class
*/
export class A2AError extends Error {
constructor(
message: string,
public code: A2AErrorCodeType = A2AErrorCode.INTERNAL_ERROR,
public data?: unknown
) {
super(message)
this.name = 'A2AError'
}
}
/**
* A2A API Response wrapper
*/
export interface A2AApiResponse<T = unknown> {
success: boolean
data?: T
error?: string
}
/**
* Database model types
*/
export interface A2AAgentConfig {
id: string
workspaceId: string
workflowId: string
name: string
description?: string
version: string
capabilities: AgentCapabilities
skills: AgentSkill[]
authentication: AgentAuthentication
isPublished: boolean
publishedAt?: Date
createdAt: Date
updatedAt: Date
}
export interface A2ATaskRecord {
id: string
agentId: string
contextId?: string
status: TaskState
history: TaskMessage[]
artifacts?: Artifact[]
executionId?: string
metadata?: Record<string, unknown>
createdAt: Date
updatedAt: Date
completedAt?: Date
}
/**
* SSE Event types for streaming
*/
export type A2AStreamEvent =
| { type: 'task:status'; data: TaskStatusUpdate }
| { type: 'task:artifact'; data: TaskArtifactUpdate }
| { type: 'task:message'; data: { id: string; message: TaskMessage } }
| { type: 'task:done'; data: { id: string } }
| { type: 'error'; data: { code: number; message: string } }

View File

@@ -1,188 +0,0 @@
/**
* A2A Protocol Utilities
*/
import { v4 as uuidv4 } from 'uuid'
import { A2A_VALID_TRANSITIONS } from './constants'
import type { MessagePart, Task, TaskMessage, TaskState, TaskStatusObject, TextPart } from './types'
/**
* Generate a unique task ID
*/
export function generateTaskId(): string {
return uuidv4()
}
/**
* Generate a unique context ID
*/
export function generateContextId(): string {
return `ctx_${uuidv4()}`
}
/**
* Check if a task status transition is valid
*/
export function isValidStatusTransition(from: TaskState, to: TaskState): boolean {
const validTransitions = A2A_VALID_TRANSITIONS[from]
return validTransitions?.includes(to) ?? false
}
/**
* Check if a task is in a terminal state
*/
export function isTerminalState(state: TaskState): boolean {
return state === 'completed' || state === 'failed' || state === 'canceled' || state === 'rejected'
}
/**
* Create a TaskStatusObject from a state
*/
export function createTaskStatus(state: TaskState, message?: TaskMessage): TaskStatusObject {
return {
state,
message,
timestamp: new Date().toISOString(),
}
}
/**
* Create a text message part
*/
export function createTextPart(text: string): TextPart {
return { type: 'text', text }
}
/**
* Create a user message
*/
export function createUserMessage(content: string | MessagePart[]): TaskMessage {
const parts = typeof content === 'string' ? [createTextPart(content)] : content
return { role: 'user', parts }
}
/**
* Create an agent message
*/
export function createAgentMessage(content: string | MessagePart[]): TaskMessage {
const parts = typeof content === 'string' ? [createTextPart(content)] : content
return { role: 'agent', parts }
}
/**
* Extract text content from a message
*/
export function extractTextContent(message: TaskMessage): string {
return message.parts
.filter((part): part is TextPart => part.type === 'text')
.map((part) => part.text)
.join('\n')
}
/**
* Extract text content from multiple messages
*/
export function extractConversationText(messages: TaskMessage[]): string {
return messages.map((m) => `${m.role}: ${extractTextContent(m)}`).join('\n\n')
}
/**
* Create an A2A tool ID from agent ID and skill ID
*/
export function createA2AToolId(agentId: string, skillId: string): string {
return `a2a:${agentId}:${skillId}`
}
/**
* Parse an A2A tool ID into components
*/
export function parseA2AToolId(toolId: string): { agentId: string; skillId: string } | null {
const parts = toolId.split(':')
if (parts.length !== 3 || parts[0] !== 'a2a') {
return null
}
return { agentId: parts[1], skillId: parts[2] }
}
/**
* Sanitize agent name for use as identifier
*/
export function sanitizeAgentName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 64)
}
/**
* Validate task structure
*/
export function validateTask(task: unknown): task is Task {
if (!task || typeof task !== 'object') return false
const t = task as Record<string, unknown>
if (typeof t.id !== 'string') return false
if (!t.status || typeof t.status !== 'object') return false
const status = t.status as Record<string, unknown>
if (typeof status.state !== 'string') return false
return true
}
/**
* Create a minimal task object
*/
export function createTask(params: {
id?: string
contextId?: string
state?: TaskState
history?: TaskMessage[]
metadata?: Record<string, unknown>
}): Task {
return {
id: params.id || generateTaskId(),
contextId: params.contextId,
status: createTaskStatus(params.state || 'submitted'),
history: params.history || [],
artifacts: [],
metadata: params.metadata,
kind: 'task',
}
}
/**
* Format task for API response (remove internal fields)
*/
export function formatTaskResponse(task: Task, historyLength?: number): Task {
let history = task.history || []
if (historyLength !== undefined && historyLength >= 0) {
history = history.slice(-historyLength)
}
return {
id: task.id,
contextId: task.contextId,
status: task.status,
history,
artifacts: task.artifacts,
metadata: task.metadata,
kind: 'task',
}
}
/**
* Build A2A endpoint URL
*/
export function buildA2AEndpointUrl(baseUrl: string, agentId: string): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/a2a/serve/${agentId}`
}
/**
* Build Agent Card URL
*/
export function buildAgentCardUrl(baseUrl: string, agentId: string): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/a2a/agents/${agentId}`
}

View File

@@ -2,7 +2,12 @@ import { db } from '@sim/db'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getHostedModels } from '@/providers/models'
import { useProvidersStore } from '@/stores/providers/store'
const logger = createLogger('BYOKKeys')
@@ -51,9 +56,6 @@ export async function getApiKeyWithBYOK(
workspaceId: string | undefined | null,
userProvidedKey?: string
): Promise<{ apiKey: string; isBYOK: boolean }> {
const { isHosted } = await import('@/lib/core/config/feature-flags')
const { useProvidersStore } = await import('@/stores/providers/store')
const isOllamaModel =
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
if (isOllamaModel) {
@@ -83,23 +85,27 @@ export async function getApiKeyWithBYOK(
workspaceId &&
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)
) {
const { getHostedModels } = await import('@/providers/models')
const hostedModels = getHostedModels()
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
if (isModelHosted || isMistralModel) {
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
if (byokResult) {
logger.info('Using BYOK key', { provider, model, workspaceId })
return byokResult
const hasEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
if (hasEnterprise) {
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
if (byokResult) {
logger.info('Using BYOK key', { provider, model, workspaceId })
return byokResult
}
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
} else {
logger.debug('Workspace not on enterprise plan, skipping BYOK', { workspaceId })
}
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
if (isModelHosted) {
try {
const { getRotatingApiKey } = await import('@/lib/core/config/api-keys')
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
return { apiKey: serverKey, isBYOK: false }
} catch (_error) {

View File

@@ -113,9 +113,8 @@ export async function checkHybridAuth(
}
}
// 3. Try API key auth (check both X-API-Key and Authorization: Bearer as fallback)
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const apiKeyHeader = request.headers.get('x-api-key') || bearerToken
// 3. Try API key auth
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
if (result.success) {

View File

@@ -0,0 +1,53 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('PlanLookup')
/**
* Get the highest priority active subscription for a user
* Priority: Enterprise > Team > Pro > Free
*/
export async function getHighestPrioritySubscription(userId: string) {
try {
const personalSubs = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]
if (allSubs.length === 0) return null
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
if (proSub) return proSub
return null
} catch (error) {
logger.error('Error getting highest priority subscription', { error, userId })
return null
}
}

View File

@@ -1,7 +1,9 @@
import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { member, subscription, user, userStats, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import {
checkEnterprisePlan,
checkProPlan,
@@ -10,65 +12,17 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
import { isProd } from '@/lib/core/config/feature-flags'
import {
isCredentialSetsEnabled,
isHosted,
isProd,
isSsoEnabled,
} from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('SubscriptionCore')
/**
* Core subscription management - single source of truth
* Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
*/
/**
* Get the highest priority active subscription for a user
* Priority: Enterprise > Team > Pro > Free
*/
export async function getHighestPrioritySubscription(userId: string) {
try {
// Get direct subscriptions
const personalSubs = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
// Get organization memberships
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
// Get organization subscriptions
let orgSubs: any[] = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]
if (allSubs.length === 0) return null
// Return highest priority subscription
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
if (proSub) return proSub
return null
} catch (error) {
logger.error('Error getting highest priority subscription', { error, userId })
return null
}
}
export { getHighestPrioritySubscription }
/**
* Check if user is on Pro plan (direct or via organization)
@@ -144,6 +98,224 @@ export async function isEnterprisePlan(userId: string): Promise<boolean> {
}
}
/**
* Check if user is an admin or owner of an enterprise organization
* Returns true if:
* - User is a member of an enterprise organization AND
* - User's role in that organization is 'owner' or 'admin'
*
* In non-production environments, returns true for convenience.
*/
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const [memberRecord] = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!memberRecord) {
return false
}
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, memberRecord.organizationId),
eq(subscription.status, 'active')
)
)
.limit(1)
const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
if (isEnterprise) {
logger.info('User is enterprise org admin/owner', {
userId,
organizationId: memberRecord.organizationId,
role: memberRecord.role,
})
}
return !!isEnterprise
} catch (error) {
logger.error('Error checking enterprise org admin/owner status', { error, userId })
return false
}
}
/**
* Check if user is an admin or owner of a team or enterprise organization
* Returns true if:
* - User is a member of a team/enterprise organization AND
* - User's role in that organization is 'owner' or 'admin'
*
* In non-production environments, returns true for convenience.
*/
export async function isTeamOrgAdminOrOwner(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const [memberRecord] = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!memberRecord) {
return false
}
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, memberRecord.organizationId),
eq(subscription.status, 'active')
)
)
.limit(1)
const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
if (hasTeamPlan) {
logger.info('User is team org admin/owner', {
userId,
organizationId: memberRecord.organizationId,
role: memberRecord.role,
plan: orgSub.plan,
})
}
return !!hasTeamPlan
} catch (error) {
logger.error('Error checking team org admin/owner status', { error, userId })
return false
}
}
/**
* Check if a workspace has access to enterprise features (BYOK)
* Used at execution time to determine if BYOK keys should be used
* Returns true if workspace's billed account is on enterprise plan
*/
export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const [ws] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!ws) {
return false
}
return isEnterprisePlan(ws.billedAccountUserId)
} catch (error) {
logger.error('Error checking workspace enterprise status', { error, workspaceId })
return false
}
}
/**
* Check if an organization has team or enterprise plan
* Used at execution time (e.g., polling services) to check org billing directly
*/
export async function isOrganizationOnTeamOrEnterprisePlan(
organizationId: string
): Promise<boolean> {
try {
if (!isProd) {
return true
}
if (isCredentialSetsEnabled && !isHosted) {
return true
}
const [orgSub] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
} catch (error) {
logger.error('Error checking organization plan status', { error, organizationId })
return false
}
}
/**
* Check if user has access to credential sets (email polling) feature
* Returns true if:
* - CREDENTIAL_SETS_ENABLED env var is set (self-hosted override), OR
* - User is admin/owner of a team/enterprise organization
*
* In non-production environments, returns true for convenience.
*/
export async function hasCredentialSetsAccess(userId: string): Promise<boolean> {
try {
if (isCredentialSetsEnabled && !isHosted) {
return true
}
return isTeamOrgAdminOrOwner(userId)
} catch (error) {
logger.error('Error checking credential sets access', { error, userId })
return false
}
}
/**
* Check if user has access to SSO feature
* Returns true if:
* - SSO_ENABLED env var is set (self-hosted override), OR
* - User is admin/owner of an enterprise organization
*
* In non-production environments, returns true for convenience.
*/
export async function hasSSOAccess(userId: string): Promise<boolean> {
try {
if (isSsoEnabled && !isHosted) {
return true
}
return isEnterpriseOrgAdminOrOwner(userId)
} catch (error) {
logger.error('Error checking SSO access', { error, userId })
return false
}
}
/**
* Check if user has exceeded their cost limit based on current period usage
*/
@@ -160,7 +332,6 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
if (subscription) {
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
limit = await getUserUsageLimit(userId)
logger.info('Using organization limit', {
userId,
@@ -221,14 +392,16 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
// Determine plan types based on subscription (avoid redundant DB calls)
const isPro =
!isProd ||
(subscription &&
!!(
subscription &&
(checkProPlan(subscription) ||
checkTeamPlan(subscription) ||
checkEnterprisePlan(subscription)))
checkEnterprisePlan(subscription))
)
const isTeam =
!isProd ||
(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
const isEnterprise = !isProd || (subscription && checkEnterprisePlan(subscription))
!!(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
const isEnterprise = !isProd || !!(subscription && checkEnterprisePlan(subscription))
const isFree = !isPro && !isTeam && !isEnterprise
// Determine plan name
@@ -244,7 +417,6 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
if (subscription) {
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
limit = await getUserUsageLimit(userId)
} else {
// Pro/Free: Use individual limit

View File

@@ -7,7 +7,7 @@ import {
renderFreeTierUpgradeEmail,
renderUsageThresholdEmail,
} from '@/components/emails'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import {
canEditUsageLimit,
getFreeTierLimit,

View File

@@ -10,9 +10,15 @@ export * from '@/lib/billing/core/subscription'
export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasCredentialSetsAccess,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
isEnterprisePlan as hasEnterprisePlan,
isOrganizationOnTeamOrEnterprisePlan,
isProPlan as hasProPlan,
isTeamOrgAdminOrOwner,
isTeamPlan as hasTeamPlan,
isWorkspaceOnEnterprisePlan,
sendPlanWelcomeEmail,
} from '@/lib/billing/core/subscription'
export * from '@/lib/billing/core/usage'

View File

@@ -248,6 +248,9 @@ export const env = createEnv({
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
// Credential Sets (Email Polling) - for self-hosted deployments
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
// SSO Configuration (for script-based registration)
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type
@@ -325,6 +328,7 @@ export const env = createEnv({
// Feature Flags
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
},
@@ -353,6 +357,7 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR,
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: process.env.NEXT_PUBLIC_TRIGGER_DEV_ENABLED,
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED,
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,

View File

@@ -80,6 +80,12 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
*/
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
/**
* Is credential sets (email polling) enabled via env var override
* This bypasses plan requirements for self-hosted deployments
*/
export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED)
/**
* Is E2B enabled for remote code execution
*/

View File

@@ -595,26 +595,6 @@ describe('validateUrlWithDNS', () => {
expect(result.isValid).toBe(false)
})
})
describe('DNS resolution', () => {
it('should accept valid public URLs and return resolved IP', async () => {
const result = await validateUrlWithDNS('https://example.com')
expect(result.isValid).toBe(true)
expect(result.resolvedIP).toBeDefined()
expect(result.originalHostname).toBe('example.com')
})
it('should reject URLs that resolve to private IPs', async () => {
const result = await validateUrlWithDNS('https://localhost.localdomain')
expect(result.isValid).toBe(false)
})
it('should reject unresolvable hostnames', async () => {
const result = await validateUrlWithDNS('https://this-domain-does-not-exist-xyz123.invalid')
expect(result.isValid).toBe(false)
expect(result.error).toContain('could not be resolved')
})
})
})
describe('createPinnedUrl', () => {

View File

@@ -1,8 +1,9 @@
import { db } from '@sim/db'
import { account, webhook, workflow } from '@sim/db/schema'
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -141,9 +142,9 @@ export async function pollGmailWebhooks() {
try {
const metadata = webhookData.providerConfig as any
// Each webhook now has its own credentialId (credential sets are fanned out at save time)
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
const credentialSetId: string | undefined = metadata?.credentialSetId
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`)
@@ -152,6 +153,31 @@ export async function pollGmailWebhooks() {
return
}
if (credentialSetId) {
const [cs] = await db
.select({ organizationId: credentialSet.organizationId })
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (cs?.organizationId) {
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
if (!hasAccess) {
logger.error(
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
{
webhookId,
credentialSetId,
organizationId: cs.organizationId,
}
)
await markWebhookFailed(webhookId)
failureCount++
return
}
}
}
let accessToken: string | null = null
if (credentialId) {

View File

@@ -1,9 +1,10 @@
import { db } from '@sim/db'
import { account, webhook, workflow } from '@sim/db/schema'
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { htmlToText } from 'html-to-text'
import { nanoid } from 'nanoid'
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -192,6 +193,7 @@ export async function pollOutlookWebhooks() {
const metadata = webhookData.providerConfig as any
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
const credentialSetId: string | undefined = metadata?.credentialSetId
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
@@ -200,6 +202,31 @@ export async function pollOutlookWebhooks() {
return
}
if (credentialSetId) {
const [cs] = await db
.select({ organizationId: credentialSet.organizationId })
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (cs?.organizationId) {
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
if (!hasAccess) {
logger.error(
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
{
webhookId,
credentialSetId,
organizationId: cs.organizationId,
}
)
await markWebhookFailed(webhookId)
failureCount++
return
}
}
}
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)

View File

@@ -239,6 +239,19 @@ export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: strin
}
}
if (webhook.provider === 'grain') {
const eventTypes = providerConfig.eventTypes
if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) {
const eventType = body?.type
if (eventType && !eventTypes.includes(eventType)) {
logger.info(
`[${requestId}] Grain event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping`
)
return true
}
}
}
return false
}

View File

@@ -2546,7 +2546,6 @@ export async function syncWebhooksForCredentialSet(params: {
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
const useUniquePaths = pollingProviders.includes(provider)
// Get all credentials in the set
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
if (credentials.length === 0) {

View File

@@ -7,6 +7,19 @@ const APP_COLORS = [
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
]
/**
* User color palette matching terminal.tsx RUN_ID_COLORS
* These colors are used consistently across cursors, avatars, and terminal run IDs
*/
export const USER_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#FCD34D', // Yellow
] as const
interface PresenceColorPalette {
gradient: string
accentColor: string
@@ -80,3 +93,35 @@ export function getPresenceColors(
baseColor: colorPair.from,
}
}
/**
* Gets a consistent color for a user based on their ID.
* The same user will always get the same color across cursors, avatars, and terminal.
*
* @param userId - The unique user identifier
* @returns A hex color string
*/
export function getUserColor(userId: string): string {
const hash = hashIdentifier(userId)
return USER_COLORS[hash % USER_COLORS.length]
}
/**
* Creates a stable mapping of user IDs to color indices for a list of users.
* Useful when you need to maintain consistent color assignments across renders.
*
* @param userIds - Array of user IDs to map
* @returns Map of user ID to color index
*/
export function createUserColorMap(userIds: string[]): Map<string, number> {
const colorMap = new Map<string, number>()
let colorIndex = 0
for (const userId of userIds) {
if (!colorMap.has(userId)) {
colorMap.set(userId, colorIndex++)
}
}
return colorMap
}

View File

@@ -1,107 +0,0 @@
/**
* A2A Cancel Task Tool
*
* Cancel a running A2A task.
*/
import { createLogger } from '@sim/logger'
import { A2A_METHODS } from '@/lib/a2a/constants'
import type { Task } from '@/lib/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
const logger = createLogger('A2ACancelTaskTool')
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
id: 'a2a_cancel_task',
name: 'A2A Cancel Task',
description: 'Cancel a running A2A task.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to cancel',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: (params: A2ACancelTaskParams) => params.agentUrl,
method: 'POST',
headers: (params: A2ACancelTaskParams) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.apiKey) {
headers.Authorization = `Bearer ${params.apiKey}`
}
return headers
},
body: (params: A2ACancelTaskParams) => ({
jsonrpc: '2.0',
id: Date.now().toString(),
method: A2A_METHODS.TASKS_CANCEL,
params: {
id: params.taskId,
},
}),
},
transformResponse: async (response: Response) => {
try {
const result = await response.json()
if (result.error) {
return {
success: false,
output: {
cancelled: false,
state: 'failed',
},
error: result.error.message || 'A2A request failed',
}
}
const task = result.result as Task
return {
success: true,
output: {
cancelled: true,
state: task.status.state,
},
}
} catch (error) {
logger.error('Error parsing A2A response:', error)
return {
success: false,
output: {
cancelled: false,
state: 'failed',
},
error: error instanceof Error ? error.message : 'Failed to parse response',
}
}
},
outputs: {
cancelled: {
type: 'boolean',
description: 'Whether cancellation was successful',
},
state: {
type: 'string',
description: 'Task state after cancellation',
},
},
}

View File

@@ -1,128 +0,0 @@
/**
* A2A Get Agent Card Tool
*
* Fetch the Agent Card (discovery document) for an A2A agent.
*/
import { createLogger } from '@sim/logger'
import type { AgentCard } from '@/lib/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
const logger = createLogger('A2AGetAgentCardTool')
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
id: 'a2a_get_agent_card',
name: 'A2A Get Agent Card',
description: 'Fetch the Agent Card (discovery document) for an A2A agent.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
apiKey: {
type: 'string',
description: 'API key for authentication (if required)',
},
},
request: {
url: (params: A2AGetAgentCardParams) => params.agentUrl,
method: 'GET',
headers: (params: A2AGetAgentCardParams) => {
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (params.apiKey) {
headers.Authorization = `Bearer ${params.apiKey}`
}
return headers
},
},
transformResponse: async (response: Response) => {
try {
if (!response.ok) {
return {
success: false,
output: {
name: '',
url: '',
version: '',
},
error: `Failed to fetch agent card: ${response.status} ${response.statusText}`,
}
}
const agentCard = (await response.json()) as AgentCard
return {
success: true,
output: {
name: agentCard.name,
description: agentCard.description,
url: agentCard.url,
version: agentCard.version,
capabilities: agentCard.capabilities,
skills: agentCard.skills,
authentication: agentCard.authentication,
defaultInputModes: agentCard.defaultInputModes,
defaultOutputModes: agentCard.defaultOutputModes,
},
}
} catch (error) {
logger.error('Error parsing Agent Card response:', error)
return {
success: false,
output: {
name: '',
url: '',
version: '',
},
error: error instanceof Error ? error.message : 'Failed to parse Agent Card',
}
}
},
outputs: {
name: {
type: 'string',
description: 'Agent name',
},
description: {
type: 'string',
description: 'Agent description',
},
url: {
type: 'string',
description: 'Agent endpoint URL',
},
version: {
type: 'string',
description: 'Agent version',
},
capabilities: {
type: 'object',
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
},
skills: {
type: 'array',
description: 'Skills the agent can perform',
},
authentication: {
type: 'object',
description: 'Supported authentication schemes',
},
defaultInputModes: {
type: 'array',
description: 'Default input modes (text, file, data)',
},
defaultOutputModes: {
type: 'array',
description: 'Default output modes (text, file, data)',
},
},
}

View File

@@ -1,127 +0,0 @@
/**
* A2A Get Task Tool
*
* Query the status of an existing A2A task.
*/
import { createLogger } from '@sim/logger'
import { A2A_METHODS } from '@/lib/a2a/constants'
import type { Task } from '@/lib/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
const logger = createLogger('A2AGetTaskTool')
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
id: 'a2a_get_task',
name: 'A2A Get Task',
description: 'Query the status of an existing A2A task.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to query',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
historyLength: {
type: 'number',
description: 'Number of history messages to include',
},
},
request: {
url: (params: A2AGetTaskParams) => params.agentUrl,
method: 'POST',
headers: (params: A2AGetTaskParams) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.apiKey) {
headers.Authorization = `Bearer ${params.apiKey}`
}
return headers
},
body: (params: A2AGetTaskParams) => ({
jsonrpc: '2.0',
id: Date.now().toString(),
method: A2A_METHODS.TASKS_GET,
params: {
id: params.taskId,
historyLength: params.historyLength,
},
}),
},
transformResponse: async (response: Response) => {
try {
const result = await response.json()
if (result.error) {
return {
success: false,
output: {
taskId: '',
state: 'failed',
},
error: result.error.message || 'A2A request failed',
}
}
const task = result.result as Task
return {
success: true,
output: {
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
}
} catch (error) {
logger.error('Error parsing A2A response:', error)
return {
success: false,
output: {
taskId: '',
state: 'failed',
},
error: error instanceof Error ? error.message : 'Failed to parse response',
}
}
},
outputs: {
taskId: {
type: 'string',
description: 'Task ID',
},
contextId: {
type: 'string',
description: 'Context ID',
},
state: {
type: 'string',
description: 'Task state',
},
artifacts: {
type: 'array',
description: 'Output artifacts',
},
history: {
type: 'array',
description: 'Message history',
},
},
}

View File

@@ -1,11 +0,0 @@
/**
* A2A Tools
*
* Tools for interacting with external A2A-compatible agents.
*/
export * from './cancel_task'
export * from './get_agent_card'
export * from './get_task'
export * from './send_task'
export * from './types'

View File

@@ -1,152 +0,0 @@
/**
* A2A Send Task Tool
*
* Send a task to an external A2A-compatible agent.
*/
import { createLogger } from '@sim/logger'
import { A2A_METHODS } from '@/lib/a2a/constants'
import type { Task, TaskMessage } from '@/lib/a2a/types'
import { extractTextContent } from '@/lib/a2a/utils'
import type { ToolConfig } from '@/tools/types'
import type { A2ASendTaskParams, A2ASendTaskResponse } from './types'
const logger = createLogger('A2ASendTaskTool')
export const a2aSendTaskTool: ToolConfig<A2ASendTaskParams, A2ASendTaskResponse> = {
id: 'a2a_send_task',
name: 'A2A Send Task',
description: 'Send a message to an external A2A-compatible agent.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
message: {
type: 'string',
required: true,
description: 'Message to send to the agent',
},
taskId: {
type: 'string',
description: 'Task ID for continuing an existing conversation',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: (params: A2ASendTaskParams) => params.agentUrl,
method: 'POST',
headers: (params: A2ASendTaskParams) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.apiKey) {
headers.Authorization = `Bearer ${params.apiKey}`
}
return headers
},
body: (params: A2ASendTaskParams) => {
const userMessage: TaskMessage = {
role: 'user',
parts: [{ type: 'text', text: params.message }],
}
return {
jsonrpc: '2.0',
id: Date.now().toString(),
method: A2A_METHODS.TASKS_SEND,
params: {
id: params.taskId,
contextId: params.contextId,
message: userMessage,
},
}
},
},
transformResponse: async (response: Response) => {
try {
const result = await response.json()
if (result.error) {
return {
success: false,
output: {
content: result.error.message || 'A2A request failed',
taskId: '',
state: 'failed',
},
error: result.error.message || 'A2A request failed',
}
}
const task = result.result as Task
// Extract content from the last agent message
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
const content = lastAgentMessage ? extractTextContent(lastAgentMessage) : ''
return {
success: true,
output: {
content,
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
}
} catch (error) {
logger.error('Error parsing A2A response:', error)
return {
success: false,
output: {
content: error instanceof Error ? error.message : 'Failed to parse response',
taskId: '',
state: 'failed',
},
error: error instanceof Error ? error.message : 'Failed to parse response',
}
}
},
outputs: {
content: {
type: 'string',
description: 'The text response from the agent',
},
taskId: {
type: 'string',
description: 'Task ID for follow-up interactions',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
state: {
type: 'string',
description: 'Task state',
},
artifacts: {
type: 'array',
description: 'Structured output artifacts',
},
history: {
type: 'array',
description: 'Full message history',
},
},
}

View File

@@ -1,119 +0,0 @@
/**
* A2A Tool Types (v0.2.6)
*/
import type {
AgentAuthentication,
AgentCapabilities,
AgentSkill,
Artifact,
InputMode,
OutputMode,
TaskMessage,
TaskState,
} from '@/lib/a2a/types'
import type { ToolResponse } from '@/tools/types'
export interface A2AGetAgentCardParams {
/** A2A agent endpoint URL */
agentUrl: string
/** API key for authentication (if required) */
apiKey?: string
}
export interface A2AGetAgentCardResponse extends ToolResponse {
output: {
/** Agent name */
name: string
/** Agent description */
description?: string
/** Agent endpoint URL */
url: string
/** Agent version */
version: string
/** Agent capabilities */
capabilities?: AgentCapabilities
/** Skills the agent can perform */
skills?: AgentSkill[]
/** Supported authentication schemes */
authentication?: AgentAuthentication
/** Default input modes */
defaultInputModes?: InputMode[]
/** Default output modes */
defaultOutputModes?: OutputMode[]
}
}
export interface A2ASendTaskParams {
/** A2A agent endpoint URL */
agentUrl: string
/** Message to send */
message: string
/** Task ID (for continuing a task) */
taskId?: string
/** Context ID (for multi-turn conversations) */
contextId?: string
/** API key for authentication */
apiKey?: string
}
export interface A2ASendTaskResponse extends ToolResponse {
output: {
/** Response content text */
content: string
/** Task ID */
taskId: string
/** Context ID */
contextId?: string
/** Task state */
state: TaskState
/** Output artifacts */
artifacts?: Artifact[]
/** Message history */
history?: TaskMessage[]
}
}
export interface A2AGetTaskParams {
/** A2A agent endpoint URL */
agentUrl: string
/** Task ID to query */
taskId: string
/** API key for authentication */
apiKey?: string
/** Number of history messages to include */
historyLength?: number
}
export interface A2AGetTaskResponse extends ToolResponse {
output: {
/** Task ID */
taskId: string
/** Context ID */
contextId?: string
/** Task state */
state: TaskState
/** Output artifacts */
artifacts?: Artifact[]
/** Message history */
history?: TaskMessage[]
}
}
export interface A2ACancelTaskParams {
/** A2A agent endpoint URL */
agentUrl: string
/** Task ID to cancel */
taskId: string
/** API key for authentication */
apiKey?: string
}
export interface A2ACancelTaskResponse extends ToolResponse {
output: {
/** Whether cancellation was successful */
cancelled: boolean
/** Task state after cancellation */
state: TaskState
}
}

View File

@@ -20,6 +20,12 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
visibility: 'user-or-llm',
description: 'Webhook endpoint URL (must respond 2xx)',
},
hookType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Type of webhook: "recording_added" or "upload_status"',
},
filterBeforeDatetime: {
type: 'string',
required: false,
@@ -81,6 +87,7 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
body: (params) => {
const body: Record<string, any> = {
hook_url: params.hookUrl,
hook_type: params.hookType,
}
const filter: Record<string, any> = {}
@@ -147,6 +154,10 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
type: 'string',
description: 'The webhook URL',
},
hook_type: {
type: 'string',
description: 'Type of hook: recording_added or upload_status',
},
filter: {
type: 'object',
description: 'Applied filters',

View File

@@ -51,6 +51,7 @@ export const grainListHooksTool: ToolConfig<GrainListHooksParams, GrainListHooks
id: { type: 'string', description: 'Hook UUID' },
enabled: { type: 'boolean', description: 'Whether hook is active' },
hook_url: { type: 'string', description: 'Webhook URL' },
hook_type: { type: 'string', description: 'Type: recording_added or upload_status' },
filter: { type: 'object', description: 'Applied filters' },
include: { type: 'object', description: 'Included fields' },
inserted_at: { type: 'string', description: 'Creation timestamp' },

View File

@@ -95,6 +95,7 @@ export interface GrainHook {
id: string
enabled: boolean
hook_url: string
hook_type: 'recording_added' | 'upload_status'
filter: GrainRecordingFilter
include: GrainRecordingInclude
inserted_at: string
@@ -192,6 +193,7 @@ export interface GrainListMeetingTypesResponse extends ToolResponse {
export interface GrainCreateHookParams {
apiKey: string
hookUrl: string
hookType: 'recording_added' | 'upload_status'
filterBeforeDatetime?: string
filterAfterDatetime?: string
filterParticipantScope?: 'internal' | 'external'

View File

@@ -19,12 +19,6 @@ export const linearCreateProjectLabelTool: ToolConfig<
},
params: {
projectId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'The project for this label',
},
name: {
type: 'string',
required: true,
@@ -71,7 +65,6 @@ export const linearCreateProjectLabelTool: ToolConfig<
},
body: (params) => {
const input: Record<string, any> = {
projectId: params.projectId,
name: params.name,
}

Some files were not shown because too many files have changed in this diff Show More