Compare commits

..

30 Commits

Author SHA1 Message Date
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
4be420311c fix(a2a): removed deployment constraint for redeploying a2a workflows (#2796)
* fix(a2a): removed deployment constraint for redeploying a2a workflows

* updated A2A tab copy state

* consolidated trigger types const
2026-01-13 13:19:57 -08:00
Waleed
b49ed2fcd9 feat(export): support maintenance of nested folder structure on import/export, added folder export admin route (#2795)
* feat(export): support maintenance of nested folder structure on import/export

* consolidated utils, added admin routes

* remove default tags from A2A
2026-01-13 12:26:41 -08:00
Waleed
837405e1ec chore(docs): update sim references in docs (#2792) 2026-01-13 11:51:43 -08:00
Waleed
2bc403972c feat(a2a): added a2a protocol (#2784)
* feat(a2a): a2a added

* feat(a2a): added a2a protocol

* remove migrations

* readd migrations

* consolidated permissions utils

* consolidated tag-input, output select -> combobox, added tags for A2A

* cleanup up utils, share same deployed state as other tabs

* ack PR comments

* more

* updated code examples

* solely rely on tanstack query to vend data and invalidate query key's, remove custom caching

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-13 11:43:02 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -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
147 changed files with 19098 additions and 1725 deletions

View File

@@ -1,11 +1,11 @@
---
description: Create a block configuration for a Sim Studio integration with proper subBlocks, conditions, and tool wiring
description: Create a block configuration for a Sim integration with proper subBlocks, conditions, and tool wiring
argument-hint: <service-name>
---
# Add Block Skill
You are an expert at creating block configurations for Sim Studio. You understand the serializer, subBlock types, conditions, dependsOn, modes, and all UI patterns.
You are an expert at creating block configurations for Sim. You understand the serializer, subBlock types, conditions, dependsOn, modes, and all UI patterns.
## Your Task

View File

@@ -1,11 +1,11 @@
---
description: Add a complete integration to Sim Studio (tools, block, icon, registration)
description: Add a complete integration to Sim (tools, block, icon, registration)
argument-hint: <service-name> [api-docs-url]
---
# Add Integration Skill
You are an expert at adding complete integrations to Sim Studio. This skill orchestrates the full process of adding a new service integration.
You are an expert at adding complete integrations to Sim. This skill orchestrates the full process of adding a new service integration.
## Overview

View File

@@ -1,11 +1,11 @@
---
description: Create tool configurations for a Sim Studio integration by reading API docs
description: Create tool configurations for a Sim integration by reading API docs
argument-hint: <service-name> [api-docs-url]
---
# Add Tools Skill
You are an expert at creating tool configurations for Sim Studio integrations. Your job is to read API documentation and create properly structured tool files.
You are an expert at creating tool configurations for Sim integrations. Your job is to read API documentation and create properly structured tool files.
## Your Task

View File

@@ -1,11 +1,11 @@
---
description: Create webhook triggers for a Sim Studio integration using the generic trigger builder
description: Create webhook triggers for a Sim integration using the generic trigger builder
argument-hint: <service-name>
---
# Add Trigger Skill
You are an expert at creating webhook triggers for Sim Studio. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
## Your Task

View File

@@ -1,4 +1,4 @@
# Sim Studio Development Guidelines
# Sim Development Guidelines
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.

View File

@@ -4078,6 +4078,31 @@ 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

@@ -4,6 +4,7 @@
import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AhrefsIcon,
AirtableIcon,
ApifyIcon,
@@ -127,6 +128,7 @@ import {
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
apify: ApifyIcon,

View File

@@ -6,13 +6,13 @@ description: Enterprise-Funktionen für Organisationen mit erweiterten
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen.
Sim 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.
Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim.
### Unterstützte Anbieter
@@ -33,7 +33,7 @@ Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der ge
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.
Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück.
---
@@ -73,5 +73,5 @@ Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgeb
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Workspace-/Organisations-Einladungen global deaktivieren |
<Callout type="warn">
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
BYOK ist nur im gehosteten Sim verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Sim Studio mit Docker Compose bereitstellen
description: Sim mit Docker Compose bereitstellen
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Umgebungsvariablen
description: Konfigurationsreferenz für Sim Studio
description: Konfigurationsreferenz für Sim
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: Self-Hosting
description: Stellen Sie Sim Studio auf Ihrer eigenen Infrastruktur bereit
description: Stellen Sie Sim auf Ihrer eigenen Infrastruktur bereit
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Stellen Sie Sim Studio auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes bereit.
Stellen Sie Sim auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes bereit.
## Anforderungen

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Sim Studio mit Helm bereitstellen
description: Sim mit Helm bereitstellen
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Cloud-Plattformen
description: Sim Studio auf Cloud-Plattformen bereitstellen
description: Sim auf Cloud-Plattformen bereitstellen
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Sim Studio bereitstellen
### Sim bereitstellen
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -5,7 +5,7 @@ description: Enterprise features for business organizations
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
Sim Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
---

View File

@@ -106,7 +106,7 @@ The model breakdown shows:
## Bring Your Own Key (BYOK)
Use your own API keys for AI model providers instead of Sim Studio's hosted keys to pay base prices with no markup.
Use your own API keys for AI model providers instead of Sim's hosted keys to pay base prices with no markup.
### Supported Providers
@@ -127,7 +127,7 @@ Use your own API keys for AI model providers instead of Sim Studio's hosted keys
BYOK keys are encrypted at rest. Only workspace admins 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 with the multiplier.
When configured, workflows use your key instead of Sim's hosted keys. If removed, workflows automatically fall back to hosted keys with the multiplier.
## Cost Optimization Strategies

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Deploy Sim Studio with Docker Compose
description: Deploy Sim with Docker Compose
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Environment Variables
description: Configuration reference for Sim Studio
description: Configuration reference for Sim
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,15 +1,15 @@
---
title: Self-Hosting
description: Deploy Sim Studio on your own infrastructure
description: Deploy Sim on your own infrastructure
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Deploy Sim Studio on your own infrastructure with Docker or Kubernetes.
Deploy Sim on your own infrastructure with Docker or Kubernetes.
<div className="flex gap-2 my-4">
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d">
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d">
<img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor" />
</a>
</div>

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Deploy Sim Studio with Helm
description: Deploy Sim with Helm
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Cloud Platforms
description: Deploy Sim Studio on cloud platforms
description: Deploy Sim on cloud platforms
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -57,7 +57,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Deploy Sim Studio
### Deploy Sim
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -0,0 +1,215 @@
---
title: A2A
description: Interact with external A2A-compatible agents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="a2a"
color="#4151B5"
/>
{/* MANUAL-CONTENT-START:intro */}
The A2A (Agent-to-Agent) protocol enables Sim to interact with external AI agents and systems that implement A2A-compatible APIs. With A2A, you can connect Sims automations and workflows to remote agents—such as LLM-powered bots, microservices, and other AI-based tools—using a standardized messaging format.
Using the A2A tools in Sim, you can:
- **Send Messages to External Agents**: Communicate directly with remote agents, providing prompts, commands, or data.
- **Receive and Stream Responses**: Get structured responses, artifacts, or real-time updates from the agent as the task progresses.
- **Continue Conversations or Tasks**: Carry on multi-turn conversations or workflows by referencing task and context IDs.
- **Integrate Third-Party AI and Automation**: Leverage external A2A-compatible services as part of your Sim workflows.
These features allow you to build advanced workflows that combine Sims native capabilities with the intelligence and automation of external AIs or custom agents. To use A2A integrations, youll need the external agents endpoint URL and, if required, an API key or credentials.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Use the A2A (Agent-to-Agent) protocol to interact with external AI agents.
## Tools
### `a2a_send_message`
Send a message to an external A2A-compatible agent.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `message` | string | Yes | Message to send to the agent |
| `taskId` | string | No | Task ID for continuing an existing task |
| `contextId` | string | No | Context ID for conversation continuity |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | The text response from the agent |
| `taskId` | string | Task ID for follow-up interactions |
| `contextId` | string | Context ID for conversation continuity |
| `state` | string | Task state |
| `artifacts` | array | Structured output artifacts |
| `history` | array | Full message history |
### `a2a_get_task`
Query the status of an existing A2A task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to query |
| `apiKey` | string | No | API key for authentication |
| `historyLength` | number | No | Number of history messages to include |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Task ID |
| `contextId` | string | Context ID |
| `state` | string | Task state |
| `artifacts` | array | Output artifacts |
| `history` | array | Message history |
### `a2a_cancel_task`
Cancel a running A2A task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to cancel |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `cancelled` | boolean | Whether cancellation was successful |
| `state` | string | Task state after cancellation |
### `a2a_get_agent_card`
Fetch the Agent Card (discovery document) for an A2A agent.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `apiKey` | string | No | API key for authentication \(if required\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Agent name |
| `description` | string | Agent description |
| `url` | string | Agent endpoint URL |
| `version` | string | Agent version |
| `capabilities` | object | Agent capabilities \(streaming, pushNotifications, etc.\) |
| `skills` | array | Skills the agent can perform |
| `defaultInputModes` | array | Default input modes \(text, file, data\) |
| `defaultOutputModes` | array | Default output modes \(text, file, data\) |
### `a2a_resubscribe`
Reconnect to an ongoing A2A task stream after connection interruption.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to resubscribe to |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Task ID |
| `contextId` | string | Context ID |
| `state` | string | Current task state |
| `isRunning` | boolean | Whether the task is still running |
| `artifacts` | array | Output artifacts |
| `history` | array | Message history |
### `a2a_set_push_notification`
Configure a webhook to receive task update notifications.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to configure notifications for |
| `webhookUrl` | string | Yes | HTTPS webhook URL to receive notifications |
| `token` | string | No | Token for webhook validation |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | Configured webhook URL |
| `token` | string | Token for webhook validation |
| `success` | boolean | Whether configuration was successful |
### `a2a_get_push_notification`
Get the push notification webhook configuration for a task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to get notification config for |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | Configured webhook URL |
| `token` | string | Token for webhook validation |
| `exists` | boolean | Whether a push notification config exists |
### `a2a_delete_push_notification`
Delete the push notification webhook configuration for a task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to delete notification config for |
| `pushNotificationConfigId` | string | No | Push notification configuration ID to delete \(optional - server can derive from taskId\) |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether deletion was successful |
## Notes
- Category: `tools`
- Type: `a2a`

View File

@@ -1,6 +1,7 @@
{
"pages": [
"index",
"a2a",
"ahrefs",
"airtable",
"apify",

View File

@@ -6,13 +6,13 @@ description: Funciones enterprise para organizaciones con requisitos avanzados
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión.
Sim 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.
Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim.
### Proveedores compatibles
@@ -33,7 +33,7 @@ Usa tus propias claves API para proveedores de modelos de IA en lugar de las cla
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.
Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas.
---
@@ -73,5 +73,5 @@ Para implementaciones self-hosted, las funciones enterprise se pueden activar me
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Desactivar globalmente invitaciones a espacios de trabajo/organizaciones |
<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.
BYOK solo está disponible en Sim alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno.
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Despliega Sim Studio con Docker Compose
description: Despliega Sim con Docker Compose
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Variables de entorno
description: Referencia de configuración para Sim Studio
description: Referencia de configuración para Sim
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: Autoalojamiento
description: Despliega Sim Studio en tu propia infraestructura
description: Despliega Sim en tu propia infraestructura
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Despliega Sim Studio en tu propia infraestructura con Docker o Kubernetes.
Despliega Sim en tu propia infraestructura con Docker o Kubernetes.
## Requisitos

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Desplegar Sim Studio con Helm
description: Desplegar Sim con Helm
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Plataformas en la nube
description: Despliega Sim Studio en plataformas en la nube
description: Despliega Sim en plataformas en la nube
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Desplegar Sim Studio
### Desplegar Sim
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -6,13 +6,13 @@ description: Fonctionnalités entreprise pour les organisations ayant des
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.
Sim 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.
Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim.
### Fournisseurs pris en charge
@@ -33,7 +33,7 @@ Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des
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.
Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées.
---
@@ -73,5 +73,5 @@ Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Désactiver globalement les invitations aux espaces de travail/organisations |
<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.
BYOK est uniquement disponible sur Sim 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

@@ -1,6 +1,6 @@
---
title: Docker
description: Déployer Sim Studio avec Docker Compose
description: Déployer Sim avec Docker Compose
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Variables d'environnement
description: Référence de configuration pour Sim Studio
description: Référence de configuration pour Sim
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: Auto-hébergement
description: Déployez Sim Studio sur votre propre infrastructure
description: Déployez Sim sur votre propre infrastructure
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Déployez Sim Studio sur votre propre infrastructure avec Docker ou Kubernetes.
Déployez Sim sur votre propre infrastructure avec Docker ou Kubernetes.
## Prérequis

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Déployer Sim Studio avec Helm
description: Déployer Sim avec Helm
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Plateformes cloud
description: Déployer Sim Studio sur des plateformes cloud
description: Déployer Sim sur des plateformes cloud
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Déployer Sim Studio
### Déployer Sim
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -5,13 +5,13 @@ description: 高度なセキュリティとコンプライアンス要件を持
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
Sim Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
---
## Bring Your Own Key (BYOK)
Sim Studioのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
Simのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
### 対応プロバイダー
@@ -32,7 +32,7 @@ Sim Studioのホストキーの代わりに、AIモデルプロバイダー用
BYOKキーは保存時に暗号化されます。組織の管理者とオーナーのみがキーを管理できます。
</Callout>
設定すると、ワークフローはSim Studioのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
設定すると、ワークフローはSimのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
---
@@ -72,5 +72,5 @@ Sim Studioのホストキーの代わりに、AIモデルプロバイダー用
| `DISABLE_INVITATIONS`、`NEXT_PUBLIC_DISABLE_INVITATIONS` | ワークスペース/組織への招待をグローバルに無効化 |
<Callout type="warn">
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
BYOKはホスト型Simでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Docker Composeを使用してSim Studioをデプロイする
description: Docker Composeを使用してSimをデプロイする
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: 環境変数
description: Sim Studioの設定リファレンス
description: Simの設定リファレンス
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: セルフホスティング
description: 自社のインフラストラクチャにSim Studioをデプロイ
description: 自社のインフラストラクチャにSimをデプロイ
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
DockerまたはKubernetesを使用して、自社のインフラストラクチャにSim Studioをデプロイします。
DockerまたはKubernetesを使用して、自社のインフラストラクチャにSimをデプロイします。
## 要件

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Helmを使用してSim Studioをデプロイする
description: Helmを使用してSimをデプロイする
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: クラウドプラットフォーム
description: クラウドプラットフォームにSim Studioをデプロイする
description: クラウドプラットフォームにSimをデプロイする
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Sim Studioのデプロイ
### Simのデプロイ
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -5,13 +5,13 @@ description: 为具有高级安全性和合规性需求的组织提供企业级
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
Sim 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
---
## 自带密钥BYOK
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim Studio 托管的密钥。
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim 托管的密钥。
### 支持的服务商
@@ -32,7 +32,7 @@ Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织
BYOK 密钥静态加密存储。仅组织管理员和所有者可管理密钥。
</Callout>
配置后,工作流将使用您的密钥而非 Sim Studio 托管密钥。如移除,工作流会自动切换回托管密钥。
配置后,工作流将使用您的密钥而非 Sim 托管密钥。如移除,工作流会自动切换回托管密钥。
---
@@ -72,5 +72,5 @@ Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织
| `DISABLE_INVITATIONS``NEXT_PUBLIC_DISABLE_INVITATIONS` | 全局禁用工作区/组织邀请 |
<Callout type="warn">
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。
BYOK 仅适用于托管版 Sim。自托管部署需通过环境变量直接配置 AI 提供商密钥。
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: 使用 Docker Compose 部署 Sim Studio
description: 使用 Docker Compose 部署 Sim
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: 环境变量
description: Sim Studio 的配置参考
description: Sim 的配置参考
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: 自托管
description: 在您自己的基础设施上部署 Sim Studio
description: 在您自己的基础设施上部署 Sim
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
使用 Docker 或 Kubernetes 在您自己的基础设施上部署 Sim Studio
使用 Docker 或 Kubernetes 在您自己的基础设施上部署 Sim。
## 要求

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: 使用 Helm 部署 Sim Studio
description: 使用 Helm 部署 Sim
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: 云平台
description: 在云平台上部署 Sim Studio
description: 在云平台上部署 Sim
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### 部署 Sim Studio
### 部署 Sim
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -6,7 +6,7 @@ export default function StructuredData() {
'@type': 'Organization',
'@id': 'https://sim.ai/#organization',
name: 'Sim',
alternateName: 'Sim Studio',
alternateName: 'Sim',
description:
'Open-source AI agent workflow builder used by developers at trail-blazing startups to Fortune 500 companies',
url: 'https://sim.ai',

View File

@@ -0,0 +1,289 @@
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 { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getRedisClient } from '@/lib/core/config/redis'
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) {
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[],
},
{
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()
if (
body.skillTags !== undefined &&
(!Array.isArray(body.skillTags) ||
!body.skillTags.every((tag: unknown): tag is string => typeof tag === 'string'))
) {
return NextResponse.json({ error: 'skillTags must be an array of strings' }, { status: 400 })
}
let skills = body.skills ?? existingAgent.skills
if (body.skillTags !== undefined) {
const agentName = body.name ?? existingAgent.name
const agentDescription = body.description ?? existingAgent.description
skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags)
}
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,
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') {
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))
const redis = getRedisClient()
if (redis) {
try {
await redis.del(`a2a:agent:${agentId}:card`)
} catch (err) {
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
}
}
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))
const redis = getRedisClient()
if (redis) {
try {
await redis.del(`a2a:agent:${agentId}:card`)
} catch (err) {
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
}
}
logger.info(`Unpublished A2A agent: ${agentId}`)
return NextResponse.json({ success: true, isPublished: false })
}
if (action === 'refresh') {
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(wf?.name || existingAgent.name, wf?.description)
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

@@ -0,0 +1,186 @@
/**
* A2A Agents List Endpoint
*
* List and create A2A agents for a workspace.
*/
import { db } from '@sim/db'
import { a2aAgent, workflow } 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'
import { getWorkspaceById } from '@/lib/workspaces/permissions/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 })
}
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
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, skillTags } =
body
if (!workspaceId || !workflowId) {
return NextResponse.json(
{ error: 'workspaceId and workflowId are required' },
{ status: 400 }
)
}
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 }
)
}
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 }
)
}
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 }
)
}
const skills = generateSkillsFromWorkflow(
name || wf.name,
description || wf.description,
skillTags
)
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 })
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
import { v4 as uuidv4 } from 'uuid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
/** A2A v0.3 JSON-RPC method names */
export const A2A_METHODS = {
MESSAGE_SEND: 'message/send',
MESSAGE_STREAM: 'message/stream',
TASKS_GET: 'tasks/get',
TASKS_CANCEL: 'tasks/cancel',
TASKS_RESUBSCRIBE: 'tasks/resubscribe',
PUSH_NOTIFICATION_SET: 'tasks/pushNotificationConfig/set',
PUSH_NOTIFICATION_GET: 'tasks/pushNotificationConfig/get',
PUSH_NOTIFICATION_DELETE: 'tasks/pushNotificationConfig/delete',
} as const
/** A2A v0.3 error codes */
export const A2A_ERROR_CODES = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
TASK_NOT_FOUND: -32001,
TASK_ALREADY_COMPLETE: -32002,
AGENT_UNAVAILABLE: -32003,
AUTHENTICATION_REQUIRED: -32004,
} as const
export interface JSONRPCRequest {
jsonrpc: '2.0'
id: string | number
method: string
params?: unknown
}
export interface JSONRPCResponse {
jsonrpc: '2.0'
id: string | number | null
result?: unknown
error?: {
code: number
message: string
data?: unknown
}
}
export interface MessageSendParams {
message: Message
configuration?: {
acceptedOutputModes?: string[]
historyLength?: number
pushNotificationConfig?: PushNotificationConfig
}
}
export interface TaskIdParams {
id: string
historyLength?: number
}
export interface PushNotificationSetParams {
id: string
pushNotificationConfig: PushNotificationConfig
}
export function createResponse(id: string | number | null, result: unknown): JSONRPCResponse {
return { jsonrpc: '2.0', id, result }
}
export function createError(
id: string | number | null,
code: number,
message: string,
data?: unknown
): JSONRPCResponse {
return { jsonrpc: '2.0', id, error: { code, message, data } }
}
export 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
}
export function generateTaskId(): string {
return uuidv4()
}
export function createTaskStatus(state: TaskState): { state: TaskState; timestamp: string } {
return { state, timestamp: new Date().toISOString() }
}
export function formatTaskResponse(task: Task, historyLength?: number): Task {
if (historyLength !== undefined && task.history) {
return {
...task,
history: task.history.slice(-historyLength),
}
}
return task
}
export interface ExecuteRequestConfig {
workflowId: string
apiKey?: string | null
stream?: boolean
}
export interface ExecuteRequestResult {
url: string
headers: Record<string, string>
useInternalAuth: boolean
}
export async function buildExecuteRequest(
config: ExecuteRequestConfig
): Promise<ExecuteRequestResult> {
const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
let useInternalAuth = false
if (config.apiKey) {
headers['X-API-Key'] = config.apiKey
} else {
const internalToken = await generateInternalToken()
headers.Authorization = `Bearer ${internalToken}`
useInternalAuth = true
}
if (config.stream) {
headers['X-Stream-Response'] = 'true'
}
return { url, headers, useInternalAuth }
}
export function extractAgentContent(executeResult: {
output?: { content?: string; [key: string]: unknown }
error?: string
}): string {
// Prefer explicit content field
if (executeResult.output?.content) {
return executeResult.output.content
}
// If output is an object with meaningful data, stringify it
if (typeof executeResult.output === 'object' && executeResult.output !== null) {
const keys = Object.keys(executeResult.output)
// Skip empty objects or objects with only undefined values
if (keys.length > 0 && keys.some((k) => executeResult.output![k] !== undefined)) {
return JSON.stringify(executeResult.output)
}
}
// Fallback to error message or default
return executeResult.error || 'Task completed'
}
export function buildTaskResponse(params: {
taskId: string
contextId: string
state: TaskState
history: Message[]
artifacts?: Artifact[]
}): Task {
return {
kind: 'task',
id: params.taskId,
contextId: params.contextId,
status: createTaskStatus(params.state),
history: params.history,
artifacts: params.artifacts || [],
}
}

View File

@@ -14,7 +14,6 @@ const updateFolderSchema = z.object({
color: z.string().optional(),
isExpanded: z.boolean().optional(),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
})
// PUT - Update a folder
@@ -39,7 +38,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
}
const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
const { name, color, isExpanded, parentId } = validationResult.data
// Verify the folder exists
const existingFolder = await db
@@ -82,12 +81,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
// Update the folder
const updates: any = { updatedAt: new Date() }
if (name !== undefined) updates.name = name.trim()
if (color !== undefined) updates.color = color
if (isExpanded !== undefined) updates.isExpanded = isExpanded
if (parentId !== undefined) updates.parentId = parentId || null
if (sortOrder !== undefined) updates.sortOrder = sortOrder
const [updatedFolder] = await db
.update(workflowFolder)

View File

@@ -1,91 +0,0 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FolderReorderAPI')
const ReorderSchema = z.object({
workspaceId: z.string(),
updates: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int().min(0),
parentId: z.string().nullable().optional(),
})
),
})
export async function PUT(req: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized folder reorder attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await req.json()
const { workspaceId, updates } = ReorderSchema.parse(body)
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission || permission === 'read') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
const folderIds = updates.map((u) => u.id)
const existingFolders = await db
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
.from(workflowFolder)
.where(inArray(workflowFolder.id, folderIds))
const validIds = new Set(
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
)
const validUpdates = updates.filter((u) => validIds.has(u.id))
if (validUpdates.length === 0) {
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
}
await db.transaction(async (tx) => {
for (const update of validUpdates) {
const updateData: Record<string, unknown> = {
sortOrder: update.sortOrder,
updatedAt: new Date(),
}
if (update.parentId !== undefined) {
updateData.parentId = update.parentId
}
await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id))
}
})
logger.info(
`[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}`
)
return NextResponse.json({ success: true, updated: validUpdates.length })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid folder reorder data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error reordering folders`, error)
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
}
}

View File

@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { memory, permissions, workspace } from '@sim/db/schema'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MemoryByIdAPI')
@@ -29,46 +30,6 @@ const memoryPutBodySchema = z.object({
workspaceId: z.string().uuid('Invalid workspace ID format'),
})
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow) {
return { hasAccess: false, canWrite: false }
}
if (workspaceRow.ownerId === userId) {
return { hasAccess: true, canWrite: true }
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permissionRow) {
return { hasAccess: false, canWrite: false }
}
return {
hasAccess: true,
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
}
}
async function validateMemoryAccess(
request: NextRequest,
workspaceId: string,
@@ -86,8 +47,8 @@ async function validateMemoryAccess(
}
}
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists || !access.hasAccess) {
return {
error: NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
@@ -96,7 +57,7 @@ async function validateMemoryAccess(
}
}
if (action === 'write' && !canWrite) {
if (action === 'write' && !access.canWrite) {
return {
error: NextResponse.json(
{ success: false, error: { message: 'Write access denied' } },

View File

@@ -1,56 +1,17 @@
import { db } from '@sim/db'
import { memory, permissions, workspace } from '@sim/db/schema'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, like } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MemoryAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow) {
return { hasAccess: false, canWrite: false }
}
if (workspaceRow.ownerId === userId) {
return { hasAccess: true, canWrite: true }
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permissionRow) {
return { hasAccess: false, canWrite: false }
}
return {
hasAccess: true,
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
}
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
@@ -76,8 +37,14 @@ export async function GET(request: NextRequest) {
)
}
const { hasAccess } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
@@ -155,15 +122,21 @@ export async function POST(request: NextRequest) {
)
}
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
)
}
if (!canWrite) {
if (!access.canWrite) {
return NextResponse.json(
{ success: false, error: { message: 'Write access denied to this workspace' } },
{ status: 403 }
@@ -282,15 +255,21 @@ export async function DELETE(request: NextRequest) {
)
}
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
)
}
if (!canWrite) {
if (!access.canWrite) {
return NextResponse.json(
{ success: false, error: { message: 'Write access denied to this workspace' } },
{ status: 403 }

View File

@@ -0,0 +1,84 @@
import type { Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('A2ACancelTaskAPI')
export const dynamic = 'force-dynamic'
const A2ACancelTaskSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A cancel task attempt`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2ACancelTaskSchema.parse(body)
logger.info(`[${requestId}] Canceling A2A task`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const task = (await client.cancelTask({ id: validatedData.taskId })) as Task
logger.info(`[${requestId}] Successfully canceled A2A task`, {
taskId: validatedData.taskId,
state: task.status.state,
})
return NextResponse.json({
success: true,
output: {
cancelled: true,
state: task.status.state,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid A2A cancel task request`, {
errors: error.errors,
})
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error canceling A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to cancel task',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ADeletePushNotificationAPI')
const A2ADeletePushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
pushNotificationConfigId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A delete push notification attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A delete push notification request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ADeletePushNotificationSchema.parse(body)
logger.info(`[${requestId}] Deleting A2A push notification config`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
pushNotificationConfigId: validatedData.pushNotificationConfigId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
await client.deleteTaskPushNotificationConfig({
id: validatedData.taskId,
pushNotificationConfigId: validatedData.pushNotificationConfigId || validatedData.taskId,
})
logger.info(`[${requestId}] Push notification config deleted successfully`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to delete push notification',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,92 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetAgentCardAPI')
const A2AGetAgentCardSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A get agent card attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A get agent card request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2AGetAgentCardSchema.parse(body)
logger.info(`[${requestId}] Fetching Agent Card`, {
agentUrl: validatedData.agentUrl,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const agentCard = await client.getAgentCard()
logger.info(`[${requestId}] Agent Card fetched successfully`, {
agentName: agentCard.name,
})
return NextResponse.json({
success: true,
output: {
name: agentCard.name,
description: agentCard.description,
url: agentCard.url,
version: agentCard.protocolVersion,
capabilities: agentCard.capabilities,
skills: agentCard.skills,
defaultInputModes: agentCard.defaultInputModes,
defaultOutputModes: agentCard.defaultOutputModes,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error fetching Agent Card:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,115 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetPushNotificationAPI')
const A2AGetPushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A get push notification attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A get push notification request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2AGetPushNotificationSchema.parse(body)
logger.info(`[${requestId}] Getting push notification config`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const result = await client.getTaskPushNotificationConfig({
id: validatedData.taskId,
})
if (!result || !result.pushNotificationConfig) {
logger.info(`[${requestId}] No push notification config found for task`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
exists: false,
},
})
}
logger.info(`[${requestId}] Push notification config retrieved successfully`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
url: result.pushNotificationConfig.url,
token: result.pushNotificationConfig.token,
exists: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
if (error instanceof Error && error.message.includes('not found')) {
logger.info(`[${requestId}] Task not found, returning exists: false`)
return NextResponse.json({
success: true,
output: {
exists: false,
},
})
}
logger.error(`[${requestId}] Error getting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get push notification',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,95 @@
import type { Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetTaskAPI')
const A2AGetTaskSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
historyLength: z.number().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A get task attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated A2A get task request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const validatedData = A2AGetTaskSchema.parse(body)
logger.info(`[${requestId}] Getting A2A task`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
historyLength: validatedData.historyLength,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const task = (await client.getTask({
id: validatedData.taskId,
historyLength: validatedData.historyLength,
})) as Task
logger.info(`[${requestId}] Successfully retrieved A2A task`, {
taskId: task.id,
state: task.status.state,
})
return NextResponse.json({
success: true,
output: {
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error getting A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get task',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,119 @@
import type {
Artifact,
Message,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatusUpdateEvent,
} from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('A2AResubscribeAPI')
export const dynamic = 'force-dynamic'
const A2AResubscribeSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A resubscribe attempt`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2AResubscribeSchema.parse(body)
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const stream = client.resubscribeTask({ id: validatedData.taskId })
let taskId = validatedData.taskId
let contextId: string | undefined
let state: TaskState = 'working'
let content = ''
let artifacts: Artifact[] = []
let history: Message[] = []
for await (const event of stream) {
if (event.kind === 'message') {
const msg = event as Message
content = extractTextContent(msg)
taskId = msg.taskId || taskId
contextId = msg.contextId || contextId
state = 'completed'
} else if (event.kind === 'task') {
const task = event as Task
taskId = task.id
contextId = task.contextId
state = task.status.state
artifacts = task.artifacts || []
history = task.history || []
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
if (lastAgentMessage) {
content = extractTextContent(lastAgentMessage)
}
} else if ('status' in event) {
const statusEvent = event as TaskStatusUpdateEvent
state = statusEvent.status.state
} else if ('artifact' in event) {
const artifactEvent = event as TaskArtifactUpdateEvent
artifacts.push(artifactEvent.artifact)
}
}
logger.info(`[${requestId}] Successfully resubscribed to A2A task ${taskId}`)
return NextResponse.json({
success: true,
output: {
taskId,
contextId,
state,
isRunning: !isTerminalState(state),
artifacts,
history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid A2A resubscribe data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error resubscribing to A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to resubscribe',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,150 @@
import type {
Artifact,
Message,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatusUpdateEvent,
} from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageStreamAPI')
const A2ASendMessageStreamSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(),
contextId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A send message stream attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A send message stream request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ASendMessageStreamSchema.parse(body)
logger.info(`[${requestId}] Sending A2A streaming message`, {
agentUrl: validatedData.agentUrl,
hasTaskId: !!validatedData.taskId,
hasContextId: !!validatedData.contextId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const message: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts: [{ kind: 'text', text: validatedData.message }],
...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }),
}
const stream = client.sendMessageStream({ message })
let taskId = ''
let contextId: string | undefined
let state: TaskState = 'working'
let content = ''
let artifacts: Artifact[] = []
let history: Message[] = []
for await (const event of stream) {
if (event.kind === 'message') {
const msg = event as Message
content = extractTextContent(msg)
taskId = msg.taskId || taskId
contextId = msg.contextId || contextId
state = 'completed'
} else if (event.kind === 'task') {
const task = event as Task
taskId = task.id
contextId = task.contextId
state = task.status.state
artifacts = task.artifacts || []
history = task.history || []
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
if (lastAgentMessage) {
content = extractTextContent(lastAgentMessage)
}
} else if ('status' in event) {
const statusEvent = event as TaskStatusUpdateEvent
state = statusEvent.status.state
} else if ('artifact' in event) {
const artifactEvent = event as TaskArtifactUpdateEvent
artifacts.push(artifactEvent.artifact)
}
}
logger.info(`[${requestId}] A2A streaming message completed`, {
taskId,
state,
artifactCount: artifacts.length,
})
return NextResponse.json({
success: isTerminalState(state) && state !== 'failed',
output: {
content,
taskId,
contextId,
state,
artifacts,
history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error in A2A streaming:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Streaming failed',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,126 @@
import type { Message, Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageAPI')
const A2ASendMessageSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(),
contextId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A send message attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A send message request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ASendMessageSchema.parse(body)
logger.info(`[${requestId}] Sending A2A message`, {
agentUrl: validatedData.agentUrl,
hasTaskId: !!validatedData.taskId,
hasContextId: !!validatedData.contextId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const message: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts: [{ kind: 'text', text: validatedData.message }],
...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }),
}
const result = await client.sendMessage({ message })
if (result.kind === 'message') {
const responseMessage = result as Message
logger.info(`[${requestId}] A2A message sent successfully (message response)`)
return NextResponse.json({
success: true,
output: {
content: extractTextContent(responseMessage),
taskId: responseMessage.taskId || '',
contextId: responseMessage.contextId,
state: 'completed',
},
})
}
const task = result as Task
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
const content = lastAgentMessage ? extractTextContent(lastAgentMessage) : ''
logger.info(`[${requestId}] A2A message sent successfully (task response)`, {
taskId: task.id,
state: task.status.state,
})
return NextResponse.json({
success: isTerminalState(task.status.state) && task.status.state !== 'failed',
output: {
content,
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error sending A2A message:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,93 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASetPushNotificationAPI')
const A2ASetPushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
webhookUrl: z.string().min(1, 'Webhook URL is required'),
token: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A set push notification attempt`, {
error: authResult.error || 'Authentication required',
})
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2ASetPushNotificationSchema.parse(body)
logger.info(`[${requestId}] A2A set push notification request`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
webhookUrl: validatedData.webhookUrl,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const result = await client.setTaskPushNotificationConfig({
taskId: validatedData.taskId,
pushNotificationConfig: {
url: validatedData.webhookUrl,
token: validatedData.token,
},
})
logger.info(`[${requestId}] A2A set push notification successful`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
url: result.pushNotificationConfig.url,
token: result.pushNotificationConfig.token,
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error setting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to set push notification',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,247 @@
/**
* GET /api/v1/admin/folders/[id]/export
*
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip)
* - JSON: FolderExportFullPayload
*/
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type FolderExportPayload,
parseWorkflowVariables,
type WorkflowExportState,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminFolderExportAPI')
interface RouteParams {
id: string
}
interface CollectedWorkflow {
id: string
folderId: string | null
}
/**
* Recursively collects all workflows within a folder and its subfolders.
*/
function collectWorkflowsInFolder(
folderId: string,
allWorkflows: Array<{ id: string; folderId: string | null }>,
allFolders: Array<{ id: string; parentId: string | null }>
): CollectedWorkflow[] {
const collected: CollectedWorkflow[] = []
for (const wf of allWorkflows) {
if (wf.folderId === folderId) {
collected.push({ id: wf.id, folderId: wf.folderId })
}
}
for (const folder of allFolders) {
if (folder.parentId === folderId) {
const childWorkflows = collectWorkflowsInFolder(folder.id, allWorkflows, allFolders)
collected.push(...childWorkflows)
}
}
return collected
}
/**
* Collects all subfolders recursively under a root folder.
* Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null.
*/
function collectSubfolders(
rootFolderId: string,
allFolders: Array<{ id: string; name: string; parentId: string | null }>
): FolderExportPayload[] {
const subfolders: FolderExportPayload[] = []
function collect(parentId: string) {
for (const folder of allFolders) {
if (folder.parentId === parentId) {
subfolders.push({
id: folder.id,
name: folder.name,
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
})
collect(folder.id)
}
}
}
collect(rootFolderId)
return subfolders
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: folderId } = await context.params
const url = new URL(request.url)
const format = url.searchParams.get('format') || 'zip'
try {
const [folderData] = await db
.select({
id: workflowFolder.id,
name: workflowFolder.name,
workspaceId: workflowFolder.workspaceId,
})
.from(workflowFolder)
.where(eq(workflowFolder.id, folderId))
.limit(1)
if (!folderData) {
return notFoundResponse('Folder')
}
const allWorkflows = await db
.select({ id: workflow.id, folderId: workflow.folderId })
.from(workflow)
.where(eq(workflow.workspaceId, folderData.workspaceId))
const allFolders = await db
.select({
id: workflowFolder.id,
name: workflowFolder.name,
parentId: workflowFolder.parentId,
})
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, folderData.workspaceId))
const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders)
const subfolders = collectSubfolders(folderId, allFolders)
const workflowExports: Array<{
workflow: {
id: string
name: string
description: string | null
color: string | null
folderId: string | null
}
state: WorkflowExportState
}> = []
for (const collectedWf of workflowsInFolder) {
try {
const [wfData] = await db
.select()
.from(workflow)
.where(eq(workflow.id, collectedWf.id))
.limit(1)
if (!wfData) {
logger.warn(`Skipping workflow ${collectedWf.id} - not found`)
continue
}
const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wfData.variables)
const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wfData.name,
description: wfData.description ?? undefined,
color: wfData.color,
exportedAt: new Date().toISOString(),
},
variables,
}
workflowExports.push({
workflow: {
id: wfData.id,
name: wfData.name,
description: wfData.description,
color: wfData.color,
folderId: remappedFolderId,
},
state,
})
} catch (error) {
logger.error(`Failed to load workflow ${collectedWf.id}:`, { error })
}
}
logger.info(
`Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders`
)
if (format === 'json') {
const exportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
folder: {
id: folderData.id,
name: folderData.name,
},
workflows: workflowExports,
folders: subfolders,
}
return singleResponse(exportPayload)
}
const zipWorkflows = workflowExports.map((wf) => ({
workflow: {
id: wf.workflow.id,
name: wf.workflow.name,
description: wf.workflow.description ?? undefined,
color: wf.workflow.color ?? undefined,
folderId: wf.workflow.folderId,
},
state: wf.state,
variables: wf.state.variables,
}))
const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders)
const arrayBuffer = await zipBlob.arrayBuffer()
const sanitizedName = sanitizePathSegment(folderData.name)
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export folder', { error, folderId })
return internalErrorResponse('Failed to export folder')
}
})

View File

@@ -34,12 +34,16 @@
* GET /api/v1/admin/workflows/:id - Get workflow details
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/export - Export multiple workflows (ZIP/JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
*
* Folders:
* GET /api/v1/admin/folders/:id/export - Export folder with contents (ZIP/JSON)
*
* Organizations:
* GET /api/v1/admin/organizations - List all organizations
* POST /api/v1/admin/organizations - Create organization (requires ownerId)

View File

@@ -1,7 +1,7 @@
/**
* GET /api/v1/admin/workflows/[id]/export
*
* Export a single workflow as JSON.
* Export a single workflow as JSON (raw, unsanitized for admin backup/restore).
*
* Response: AdminSingleResponse<WorkflowExportPayload>
*/

View File

@@ -0,0 +1,147 @@
/**
* POST /api/v1/admin/workflows/export
*
* Export multiple workflows as a ZIP file or JSON array (raw, unsanitized for admin backup/restore).
*
* Request Body:
* - ids: string[] - Array of workflow IDs to export
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip) - each workflow as JSON in root
* - JSON: AdminListResponse<WorkflowExportPayload[]>
*/
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { inArray } from 'drizzle-orm'
import JSZip from 'jszip'
import { NextResponse } from 'next/server'
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
parseWorkflowVariables,
type WorkflowExportPayload,
type WorkflowExportState,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowsExportAPI')
interface ExportRequest {
ids: string[]
}
export const POST = withAdminAuth(async (request) => {
const url = new URL(request.url)
const format = url.searchParams.get('format') || 'zip'
let body: ExportRequest
try {
body = await request.json()
} catch {
return badRequestResponse('Invalid JSON body')
}
if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) {
return badRequestResponse('ids must be a non-empty array of workflow IDs')
}
try {
const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids))
if (workflows.length === 0) {
return badRequestResponse('No workflows found with the provided IDs')
}
const workflowExports: WorkflowExportPayload[] = []
for (const wf of workflows) {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wf.variables)
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wf.name,
description: wf.description ?? undefined,
color: wf.color,
exportedAt: new Date().toISOString(),
},
variables,
}
const exportPayload: WorkflowExportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
workflow: {
id: wf.id,
name: wf.name,
description: wf.description,
color: wf.color,
workspaceId: wf.workspaceId,
folderId: wf.folderId,
},
state,
}
workflowExports.push(exportPayload)
} catch (error) {
logger.error(`Failed to load workflow ${wf.id}:`, { error })
}
}
logger.info(`Admin API: Exporting ${workflowExports.length} workflows`)
if (format === 'json') {
return listResponse(workflowExports, {
total: workflowExports.length,
limit: workflowExports.length,
offset: 0,
hasMore: false,
})
}
const zip = new JSZip()
for (const exportPayload of workflowExports) {
const filename = `${sanitizePathSegment(exportPayload.workflow.name)}.json`
zip.file(filename, JSON.stringify(exportPayload, null, 2))
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const arrayBuffer = await zipBlob.arrayBuffer()
const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip`
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export workflows', { error, ids: body.ids })
return internalErrorResponse('Failed to export workflows')
}
})

View File

@@ -1,7 +1,7 @@
/**
* GET /api/v1/admin/workspaces/[id]/export
*
* Export an entire workspace as a ZIP file or JSON.
* Export an entire workspace as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
@@ -16,7 +16,7 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { exportWorkspaceToZip } from '@/lib/workflows/operations/import-export'
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -146,7 +146,7 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports)
const arrayBuffer = await zipBlob.arrayBuffer()
const sanitizedName = workspaceData.name.replace(/[^a-z0-9-_]/gi, '-')
const sanitizedName = sanitizePathSegment(workspaceData.name)
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
return new NextResponse(arrayBuffer, {

View File

@@ -27,7 +27,7 @@ import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('WorkflowExecuteAPI')
@@ -109,7 +109,7 @@ type AsyncExecutionParams = {
workflowId: string
userId: string
input: any
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
triggerType: CoreTriggerType
}
/**
@@ -215,10 +215,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowStateOverride,
} = validation.data
// For API key auth, the entire body is the input (except for our control fields)
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input =
auth.authType === 'api_key'
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
? (() => {
const {
selectedOutputs,
@@ -226,6 +226,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
stream,
useDraftState,
workflowStateOverride,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
...rest
} = body
return Object.keys(rest).length > 0 ? rest : validatedInput
@@ -252,17 +253,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
const executionId = uuidv4()
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
let loggingTriggerType: LoggingTriggerType = 'manual'
if (
triggerType === 'api' ||
triggerType === 'chat' ||
triggerType === 'webhook' ||
triggerType === 'schedule' ||
triggerType === 'manual' ||
triggerType === 'mcp'
) {
loggingTriggerType = triggerType as LoggingTriggerType
let loggingTriggerType: CoreTriggerType = 'manual'
if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) {
loggingTriggerType = triggerType as CoreTriggerType
}
const loggingSession = new LoggingSession(
workflowId,

View File

@@ -20,7 +20,6 @@ const UpdateWorkflowSchema = z.object({
description: z.string().optional(),
color: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
})
/**
@@ -439,12 +438,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
// Build update object
const updateData: any = { updatedAt: new Date() }
if (updates.name !== undefined) updateData.name = updates.name
if (updates.description !== undefined) updateData.description = updates.description
if (updates.color !== undefined) updateData.color = updates.color
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
// Update the workflow
const [updatedWorkflow] = await db

View File

@@ -1,91 +0,0 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowReorderAPI')
const ReorderSchema = z.object({
workspaceId: z.string(),
updates: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int().min(0),
folderId: z.string().nullable().optional(),
})
),
})
export async function PUT(req: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized reorder attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await req.json()
const { workspaceId, updates } = ReorderSchema.parse(body)
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission || permission === 'read') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
const workflowIds = updates.map((u) => u.id)
const existingWorkflows = await db
.select({ id: workflow.id, workspaceId: workflow.workspaceId })
.from(workflow)
.where(inArray(workflow.id, workflowIds))
const validIds = new Set(
existingWorkflows.filter((w) => w.workspaceId === workspaceId).map((w) => w.id)
)
const validUpdates = updates.filter((u) => validIds.has(u.id))
if (validUpdates.length === 0) {
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
}
await db.transaction(async (tx) => {
for (const update of validUpdates) {
const updateData: Record<string, unknown> = {
sortOrder: update.sortOrder,
updatedAt: new Date(),
}
if (update.folderId !== undefined) {
updateData.folderId = update.folderId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, update.id))
}
})
logger.info(
`[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`
)
return NextResponse.json({ success: true, updated: validUpdates.length })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid reorder data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error reordering workflows`, error)
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
}
}

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { workflow, workspace } from '@sim/db/schema'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, max } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowAPI')
@@ -36,13 +36,9 @@ export async function GET(request: Request) {
const userId = session.user.id
if (workspaceId) {
const workspaceExists = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.then((rows) => rows.length > 0)
const wsExists = await workspaceExists(workspaceId)
if (!workspaceExists) {
if (!wsExists) {
logger.warn(
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
)
@@ -131,23 +127,11 @@ export async function POST(req: NextRequest) {
// Silently fail
})
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
const [maxResult] = await db
.select({ maxOrder: max(workflow.sortOrder) })
.from(workflow)
.where(
workspaceId
? and(eq(workflow.workspaceId, workspaceId), folderCondition)
: and(eq(workflow.userId, session.user.id), folderCondition)
)
const sortOrder = (maxResult?.maxOrder ?? -1) + 1
await db.insert(workflow).values({
id: workflowId,
userId: session.user.id,
workspaceId: workspaceId || null,
folderId: folderId || null,
sortOrder,
name,
description,
color,
@@ -168,7 +152,6 @@ export async function POST(req: NextRequest) {
color,
workspaceId,
folderId,
sortOrder,
createdAt: now,
updatedAt: now,
})

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { apiKey, workspace } from '@sim/db/schema'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
@@ -9,7 +9,7 @@ import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceApiKeysAPI')
@@ -34,8 +34,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { workspace, workspaceBYOKKeys } from '@sim/db/schema'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
@@ -8,7 +8,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceBYOKKeysAPI')
@@ -46,8 +46,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { environment, workspace, workspaceEnvironment } from '@sim/db/schema'
import { environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -7,7 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -33,8 +33,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
// Validate workspace exists
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -11,9 +11,9 @@ export const metadata: Metadata = {
'Open-source AI agent workflow builder used by 60,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.',
keywords:
'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI',
authors: [{ name: 'Sim Studio' }],
creator: 'Sim Studio',
publisher: 'Sim Studio',
authors: [{ name: 'Sim' }],
creator: 'Sim',
publisher: 'Sim',
formatDetection: {
email: false,
address: false,

View File

@@ -364,12 +364,30 @@ export default function PlaygroundPage() {
</VariantRow>
<VariantRow label='tag variants'>
<Tag value='valid@email.com' variant='default' />
<Tag value='secondary-tag' variant='secondary' />
<Tag value='invalid-email' variant='invalid' />
</VariantRow>
<VariantRow label='tag with remove'>
<Tag value='removable@tag.com' variant='default' onRemove={() => {}} />
<Tag value='secondary-removable' variant='secondary' onRemove={() => {}} />
<Tag value='invalid-removable' variant='invalid' onRemove={() => {}} />
</VariantRow>
<VariantRow label='secondary variant'>
<div className='w-80'>
<TagInput
items={[
{ value: 'workflow', isValid: true },
{ value: 'automation', isValid: true },
]}
onAdd={() => true}
onRemove={() => {}}
placeholder='Add tags'
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
/>
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-80'>
<TagInput

View File

@@ -72,6 +72,7 @@ const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['va
schedule: 'green',
chat: 'purple',
webhook: 'orange',
a2a: 'teal',
}
interface StatusBadgeProps {

View File

@@ -888,7 +888,7 @@ export function Chat() {
selectedOutputs={selectedOutputs}
onOutputSelect={handleOutputSelection}
disabled={!activeWorkflowId}
placeholder='Select outputs'
placeholder='Outputs'
align='end'
maxHeight={180}
/>

View File

@@ -1,16 +1,9 @@
'use client'
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, RepeatIcon, SplitIcon } from 'lucide-react'
import {
Badge,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import { useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
import {
extractFieldsFromSchema,
parseResponseFormatSafely,
@@ -21,7 +14,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Renders a tag icon with background color.
* Renders a tag icon with background color for block section headers.
*
* @param icon - Either a letter string or a Lucide icon component
* @param color - Background color for the icon container
@@ -62,14 +55,9 @@ interface OutputSelectProps {
placeholder?: string
/** Whether to emit output IDs or labels in onOutputSelect callback */
valueMode?: 'id' | 'label'
/**
* When true, renders the underlying popover content inline instead of in a portal.
* Useful when used inside dialogs or other portalled components that manage scroll locking.
*/
disablePopoverPortal?: boolean
/** Alignment of the popover relative to the trigger */
/** Alignment of the dropdown relative to the trigger */
align?: 'start' | 'end' | 'center'
/** Maximum height of the popover content in pixels */
/** Maximum height of the dropdown content in pixels */
maxHeight?: number
}
@@ -90,14 +78,9 @@ export function OutputSelect({
disabled = false,
placeholder = 'Select outputs',
valueMode = 'id',
disablePopoverPortal = false,
align = 'start',
maxHeight = 200,
}: OutputSelectProps) {
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const blocks = useWorkflowStore((state) => state.blocks)
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
const subBlockValues = useSubBlockStore((state) =>
@@ -206,21 +189,10 @@ export function OutputSelect({
shouldUseBaseline,
])
/**
* Checks if an output is currently selected by comparing both ID and label
* @param o - The output object to check
* @returns True if the output is selected, false otherwise
*/
const isSelectedValue = useCallback(
(o: { id: string; label: string }) =>
selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label),
[selectedOutputs]
)
/**
* Gets display text for selected outputs
*/
const selectedOutputsDisplayText = useMemo(() => {
const selectedDisplayText = useMemo(() => {
if (!selectedOutputs || selectedOutputs.length === 0) {
return placeholder
}
@@ -234,19 +206,27 @@ export function OutputSelect({
}
if (validOutputs.length === 1) {
const output = workflowOutputs.find(
(o) => o.id === validOutputs[0] || o.label === validOutputs[0]
)
return output?.label || placeholder
return '1 output'
}
return `${validOutputs.length} outputs`
}, [selectedOutputs, workflowOutputs, placeholder])
/**
* Groups outputs by block and sorts by distance from starter block
* Gets the background color for a block output based on its type
* @param blockType - The type of the block
* @returns The hex color code for the block
*/
const groupedOutputs = useMemo(() => {
const getOutputColor = (blockType: string) => {
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF'
}
/**
* Groups outputs by block and sorts by distance from starter block.
* Returns ComboboxOptionGroup[] for use with Combobox.
*/
const comboboxGroups = useMemo((): ComboboxOptionGroup[] => {
const groups: Record<string, typeof workflowOutputs> = {}
const blockDistances: Record<string, number> = {}
const edges = useWorkflowStore.getState().edges
@@ -283,242 +263,75 @@ export function OutputSelect({
groups[output.blockName].push(output)
})
return Object.entries(groups)
const sortedGroups = Object.entries(groups)
.map(([blockName, outputs]) => ({
blockName,
outputs,
distance: blockDistances[outputs[0]?.blockId] || 0,
}))
.sort((a, b) => b.distance - a.distance)
.reduce(
(acc, { blockName, outputs }) => {
acc[blockName] = outputs
return acc
},
{} as Record<string, typeof workflowOutputs>
)
}, [workflowOutputs, blocks])
/**
* Gets the background color for a block output based on its type
* @param blockId - The block ID (unused but kept for future extensibility)
* @param blockType - The type of the block
* @returns The hex color code for the block
*/
const getOutputColor = (blockId: string, blockType: string) => {
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF'
}
return sortedGroups.map(({ blockName, outputs }) => {
const firstOutput = outputs[0]
const blockConfig = getBlock(firstOutput.blockType)
const blockColor = getOutputColor(firstOutput.blockType)
/**
* Flattened outputs for keyboard navigation
*/
const flattenedOutputs = useMemo(() => {
return Object.values(groupedOutputs).flat()
}, [groupedOutputs])
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
.charAt(0)
.toUpperCase()
/**
* Handles output selection by toggling the selected state
* @param value - The output label to toggle
*/
const handleOutputSelection = useCallback(
(value: string) => {
const emittedValue =
valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value
const index = selectedOutputs.indexOf(emittedValue)
const newSelectedOutputs =
index === -1
? [...new Set([...selectedOutputs, emittedValue])]
: selectedOutputs.filter((id) => id !== emittedValue)
onOutputSelect(newSelectedOutputs)
},
[valueMode, workflowOutputs, selectedOutputs, onOutputSelect]
)
/**
* Handles keyboard navigation within the output list
* Supports ArrowUp, ArrowDown, Enter, and Escape keys
*/
useEffect(() => {
if (!open || flattenedOutputs.length === 0) return
const handleKeyboardEvent = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setHighlightedIndex((prev) => {
if (prev === -1 || prev >= flattenedOutputs.length - 1) {
return 0
}
return prev + 1
})
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setHighlightedIndex((prev) => {
if (prev <= 0) {
return flattenedOutputs.length - 1
}
return prev - 1
})
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
setHighlightedIndex((currentIndex) => {
if (currentIndex >= 0 && currentIndex < flattenedOutputs.length) {
handleOutputSelection(flattenedOutputs[currentIndex].label)
}
return currentIndex
})
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setOpen(false)
break
if (blockConfig?.icon) {
blockIcon = blockConfig.icon
} else if (firstOutput.blockType === 'loop') {
blockIcon = RepeatIcon
} else if (firstOutput.blockType === 'parallel') {
blockIcon = SplitIcon
}
}
window.addEventListener('keydown', handleKeyboardEvent, true)
return () => window.removeEventListener('keydown', handleKeyboardEvent, true)
}, [open, flattenedOutputs, handleOutputSelection])
/**
* Reset highlighted index when popover opens/closes
*/
useEffect(() => {
if (open) {
const firstSelectedIndex = flattenedOutputs.findIndex((output) => isSelectedValue(output))
setHighlightedIndex(firstSelectedIndex >= 0 ? firstSelectedIndex : -1)
} else {
setHighlightedIndex(-1)
}
}, [open, flattenedOutputs, isSelectedValue])
/**
* Scroll highlighted item into view
*/
useEffect(() => {
if (highlightedIndex >= 0 && popoverRef.current) {
const highlightedElement = popoverRef.current.querySelector(
`[data-option-index="${highlightedIndex}"]`
)
if (highlightedElement) {
highlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
return {
sectionElement: (
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
<TagIcon icon={blockIcon} color={blockColor} />
<span className='font-medium text-[13px]'>{blockName}</span>
</div>
),
items: outputs.map((output) => ({
label: output.path,
value: valueMode === 'label' ? output.label : output.id,
})),
}
}
}, [highlightedIndex])
})
}, [workflowOutputs, blocks, valueMode])
/**
* Closes popover when clicking outside
* Normalize selected values to match the valueMode
*/
useEffect(() => {
if (!open) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
const insideTrigger = triggerRef.current?.contains(target)
const insidePopover = popoverRef.current?.contains(target)
if (!insideTrigger && !insidePopover) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const normalizedSelectedValues = useMemo(() => {
return selectedOutputs
.map((val) => {
// Find the output that matches either id or label
const output = workflowOutputs.find((o) => o.id === val || o.label === val)
if (!output) return null
// Return in the format matching valueMode
return valueMode === 'label' ? output.label : output.id
})
.filter((v): v is string => v !== null)
}, [selectedOutputs, workflowOutputs, valueMode])
return (
<Popover open={open} variant='default'>
<PopoverTrigger asChild>
<div ref={triggerRef} className='min-w-0 max-w-full'>
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
title='Select outputs'
aria-expanded={open}
onMouseDown={(e) => {
if (disabled || workflowOutputs.length === 0) return
e.stopPropagation()
setOpen((prev) => !prev)
}}
>
<span className='whitespace-nowrap text-[12px]'>{selectedOutputsDisplayText}</span>
</Badge>
</div>
</PopoverTrigger>
<PopoverContent
ref={popoverRef}
side='bottom'
align={align}
sideOffset={4}
maxHeight={maxHeight}
maxWidth={300}
minWidth={160}
border
disablePortal={disablePopoverPortal}
>
<div className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
const firstOutput = outputs[0]
const blockConfig = getBlock(firstOutput.blockType)
const blockColor = getOutputColor(firstOutput.blockId, firstOutput.blockType)
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
.charAt(0)
.toUpperCase()
if (blockConfig?.icon) {
blockIcon = blockConfig.icon
} else if (firstOutput.blockType === 'loop') {
blockIcon = RepeatIcon
} else if (firstOutput.blockType === 'parallel') {
blockIcon = SplitIcon
}
return (
<div key={blockName}>
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
<TagIcon icon={blockIcon} color={blockColor} />
<span className='font-medium text-[13px]'>{blockName}</span>
</div>
<div className='flex flex-col gap-[2px]'>
{outputs.map((output, localIndex) => {
const globalIndex = startIndex + localIndex
const isHighlighted = globalIndex === highlightedIndex
return (
<PopoverItem
key={output.id}
active={isSelectedValue(output) || isHighlighted}
data-option-index={globalIndex}
onClick={() => handleOutputSelection(output.label)}
onMouseEnter={() => setHighlightedIndex(globalIndex)}
>
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
</PopoverItem>
)
})}
</div>
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
</div>
)
})}
</div>
</PopoverContent>
</Popover>
<Combobox
size='sm'
className='!w-fit !py-[2px] [&>svg]:!ml-[4px] [&>svg]:!h-3 [&>svg]:!w-3 [&>span]:!text-[var(--text-secondary)] min-w-[100px] rounded-[6px] bg-transparent px-[9px] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-6)] dark:hover:bg-transparent [&>span]:text-center'
groups={comboboxGroups}
options={[]}
multiSelect
multiSelectValues={normalizedSelectedValues}
onMultiSelectChange={onOutputSelect}
placeholder={selectedDisplayText}
disabled={disabled || workflowOutputs.length === 0}
align={align}
maxHeight={maxHeight}
dropdownWidth={180}
/>
)
}

View File

@@ -0,0 +1,941 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, Clipboard } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Checkbox,
Code,
Combobox,
type ComboboxOption,
Input,
Label,
TagInput,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
import {
useA2AAgentByWorkflow,
useCreateA2AAgent,
useDeleteA2AAgent,
usePublishA2AAgent,
useUpdateA2AAgent,
} from '@/hooks/queries/a2a/agents'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('A2ADeploy')
interface InputFormatField {
id?: string
name?: string
type?: string
value?: unknown
collapsed?: boolean
}
/**
* Check if a description is a default/placeholder value that should be filtered out
*/
function isDefaultDescription(desc: string | null | undefined, workflowName: string): boolean {
if (!desc) return true
const normalized = desc.toLowerCase().trim()
return (
normalized === '' || normalized === 'new workflow' || normalized === workflowName.toLowerCase()
)
}
type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript'
const LANGUAGE_LABELS: Record<CodeLanguage, string> = {
curl: 'cURL',
python: 'Python',
javascript: 'JavaScript',
typescript: 'TypeScript',
}
const LANGUAGE_SYNTAX: Record<CodeLanguage, 'python' | 'javascript' | 'json'> = {
curl: 'javascript',
python: 'python',
javascript: 'javascript',
typescript: 'javascript',
}
interface A2aDeployProps {
workflowId: string
workflowName: string
workflowDescription?: string | null
isDeployed: boolean
workflowNeedsRedeployment?: boolean
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
onAgentExistsChange?: (exists: boolean) => void
onPublishedChange?: (published: boolean) => void
onNeedsRepublishChange?: (needsRepublish: boolean) => void
onDeployWorkflow?: () => Promise<void>
}
type AuthScheme = 'none' | 'apiKey'
export function A2aDeploy({
workflowId,
workflowName,
workflowDescription,
isDeployed,
workflowNeedsRedeployment,
onSubmittingChange,
onCanSaveChange,
onAgentExistsChange,
onPublishedChange,
onNeedsRepublishChange,
onDeployWorkflow,
}: A2aDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { data: existingAgent, isLoading } = useA2AAgentByWorkflow(workspaceId, workflowId)
const createAgent = useCreateA2AAgent()
const updateAgent = useUpdateA2AAgent()
const deleteAgent = useDeleteA2AAgent()
const publishAgent = usePublishA2AAgent()
const blocks = useWorkflowStore((state) => state.blocks)
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const startBlockId = useMemo(() => {
if (!blocks || Object.keys(blocks).length === 0) return null
const candidate = TriggerUtils.findStartBlock(blocks, 'api')
if (!candidate || candidate.path !== StartBlockPath.UNIFIED) return null
return candidate.blockId
}, [blocks])
const startBlockInputFormat = useSubBlockStore((state) => {
if (!workflowId || !startBlockId) return null
const workflowValues = state.workflowValues[workflowId]
const fromStore = workflowValues?.[startBlockId]?.inputFormat
if (fromStore !== undefined) return fromStore
const startBlock = blocks[startBlockId]
return startBlock?.subBlocks?.inputFormat?.value ?? null
})
const missingFields = useMemo(() => {
if (!startBlockId) return { input: false, data: false, files: false, any: false }
const normalizedFields = normalizeInputFormatValue(startBlockInputFormat)
const existingNames = new Set(
normalizedFields
.map((field) => field.name)
.filter((n): n is string => typeof n === 'string' && n.trim() !== '')
.map((n) => n.trim().toLowerCase())
)
const missing = {
input: !existingNames.has('input'),
data: !existingNames.has('data'),
files: !existingNames.has('files'),
any: false,
}
missing.any = missing.input || missing.data || missing.files
return missing
}, [startBlockId, startBlockInputFormat])
const handleAddA2AInputs = useCallback(() => {
if (!startBlockId) return
const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat)
const newFields: InputFormatField[] = []
// Add input field if missing (for TextPart)
if (missingFields.input) {
newFields.push({
id: crypto.randomUUID(),
name: 'input',
type: 'string',
value: '',
collapsed: false,
})
}
// Add data field if missing (for DataPart)
if (missingFields.data) {
newFields.push({
id: crypto.randomUUID(),
name: 'data',
type: 'object',
value: '',
collapsed: false,
})
}
// Add files field if missing (for FilePart)
if (missingFields.files) {
newFields.push({
id: crypto.randomUUID(),
name: 'files',
type: 'files',
value: '',
collapsed: false,
})
}
if (newFields.length > 0) {
const updatedFields = [...newFields, ...normalizedExisting]
collaborativeSetSubblockValue(startBlockId, 'inputFormat', updatedFields)
logger.info(
`Added A2A input fields to Start block: ${newFields.map((f) => f.name).join(', ')}`
)
}
}, [startBlockId, startBlockInputFormat, missingFields, collaborativeSetSubblockValue])
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [authScheme, setAuthScheme] = useState<AuthScheme>('apiKey')
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false)
const [skillTags, setSkillTags] = useState<string[]>([])
const [language, setLanguage] = useState<CodeLanguage>('curl')
const [useStreamingExample, setUseStreamingExample] = useState(false)
const [urlCopied, setUrlCopied] = useState(false)
const [codeCopied, setCodeCopied] = useState(false)
useEffect(() => {
if (existingAgent) {
setName(existingAgent.name)
const savedDesc = existingAgent.description || ''
setDescription(isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc)
setPushNotificationsEnabled(existingAgent.capabilities?.pushNotifications ?? false)
const schemes = existingAgent.authentication?.schemes || []
if (schemes.includes('apiKey')) {
setAuthScheme('apiKey')
} else {
setAuthScheme('none')
}
const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
const savedTags = skills?.[0]?.tags
setSkillTags(savedTags?.length ? savedTags : [])
} else {
setName(workflowName)
setDescription(
isDefaultDescription(workflowDescription, workflowName) ? '' : workflowDescription || ''
)
setAuthScheme('apiKey')
setPushNotificationsEnabled(false)
setSkillTags([])
}
}, [existingAgent, workflowName, workflowDescription])
useEffect(() => {
onAgentExistsChange?.(!!existingAgent)
}, [existingAgent, onAgentExistsChange])
useEffect(() => {
onPublishedChange?.(existingAgent?.isPublished ?? false)
}, [existingAgent?.isPublished, onPublishedChange])
const hasFormChanges = useMemo(() => {
if (!existingAgent) return false
const savedSchemes = existingAgent.authentication?.schemes || []
const savedAuthScheme = savedSchemes.includes('apiKey') ? 'apiKey' : 'none'
const savedDesc = existingAgent.description || ''
const normalizedSavedDesc = isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc
const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
const savedTags = skills?.[0]?.tags || []
const tagsChanged =
skillTags.length !== savedTags.length || skillTags.some((t, i) => t !== savedTags[i])
return (
name !== existingAgent.name ||
description !== normalizedSavedDesc ||
pushNotificationsEnabled !== (existingAgent.capabilities?.pushNotifications ?? false) ||
authScheme !== savedAuthScheme ||
tagsChanged
)
}, [
existingAgent,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workflowName,
])
const hasWorkflowChanges = useMemo(() => {
if (!existingAgent) return false
return !!workflowNeedsRedeployment
}, [existingAgent, workflowNeedsRedeployment])
const needsRepublish = existingAgent && (hasFormChanges || hasWorkflowChanges)
useEffect(() => {
onNeedsRepublishChange?.(!!needsRepublish)
}, [needsRepublish, onNeedsRepublishChange])
const authSchemeOptions: ComboboxOption[] = useMemo(
() => [
{ label: 'API Key', value: 'apiKey' },
{ label: 'None (Public)', value: 'none' },
],
[]
)
const canSave = name.trim().length > 0 && description.trim().length > 0
useEffect(() => {
onCanSaveChange?.(canSave)
}, [canSave, onCanSaveChange])
const isSubmitting =
createAgent.isPending ||
updateAgent.isPending ||
deleteAgent.isPending ||
publishAgent.isPending
useEffect(() => {
onSubmittingChange?.(isSubmitting)
}, [isSubmitting, onSubmittingChange])
const handleCreateOrUpdate = useCallback(async () => {
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
stateTransitionHistory: true,
}
const authentication: AgentAuthentication = {
schemes: authScheme === 'none' ? ['none'] : [authScheme],
}
try {
if (existingAgent) {
await updateAgent.mutateAsync({
agentId: existingAgent.id,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
} else {
await createAgent.mutateAsync({
workspaceId,
workflowId,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
}
} catch (error) {
logger.error('Failed to save A2A agent:', error)
}
}, [
existingAgent,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
workflowId,
createAgent,
updateAgent,
])
const handlePublish = useCallback(async () => {
if (!existingAgent) return
try {
await publishAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
action: 'publish',
})
} catch (error) {
logger.error('Failed to publish A2A agent:', error)
}
}, [existingAgent, workspaceId, publishAgent])
const handleUnpublish = useCallback(async () => {
if (!existingAgent) return
try {
await publishAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
action: 'unpublish',
})
} catch (error) {
logger.error('Failed to unpublish A2A agent:', error)
}
}, [existingAgent, workspaceId, publishAgent])
const handleDelete = useCallback(async () => {
if (!existingAgent) return
try {
await deleteAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
})
setName(workflowName)
setDescription(workflowDescription || '')
} catch (error) {
logger.error('Failed to delete A2A agent:', error)
}
}, [existingAgent, workspaceId, deleteAgent, workflowName, workflowDescription])
const handlePublishNewAgent = useCallback(async () => {
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
stateTransitionHistory: true,
}
const authentication: AgentAuthentication = {
schemes: authScheme === 'none' ? ['none'] : [authScheme],
}
try {
if (!isDeployed && onDeployWorkflow) {
await onDeployWorkflow()
}
const newAgent = await createAgent.mutateAsync({
workspaceId,
workflowId,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
await publishAgent.mutateAsync({
agentId: newAgent.id,
workspaceId,
action: 'publish',
})
} catch (error) {
logger.error('Failed to publish A2A agent:', error)
}
}, [
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
workflowId,
createAgent,
publishAgent,
isDeployed,
onDeployWorkflow,
])
const handleUpdateAndRepublish = useCallback(async () => {
if (!existingAgent) return
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
stateTransitionHistory: true,
}
const authentication: AgentAuthentication = {
schemes: authScheme === 'none' ? ['none'] : [authScheme],
}
try {
if ((!isDeployed || workflowNeedsRedeployment) && onDeployWorkflow) {
await onDeployWorkflow()
}
await updateAgent.mutateAsync({
agentId: existingAgent.id,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
await publishAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
action: 'publish',
})
} catch (error) {
logger.error('Failed to update and republish A2A agent:', error)
}
}, [
existingAgent,
isDeployed,
workflowNeedsRedeployment,
onDeployWorkflow,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
updateAgent,
publishAgent,
])
const baseUrl = getBaseUrl()
const endpoint = existingAgent ? `${baseUrl}/api/a2a/serve/${existingAgent.id}` : null
const additionalInputFields = useMemo(() => {
const allFields = normalizeInputFormatValue(startBlockInputFormat)
return allFields.filter(
(field): field is InputFormatField & { name: string } =>
!!field.name &&
field.name.toLowerCase() !== 'input' &&
field.name.toLowerCase() !== 'data' &&
field.name.toLowerCase() !== 'files'
)
}, [startBlockInputFormat])
const getExampleInputData = useCallback((): Record<string, unknown> => {
const data: Record<string, unknown> = {}
for (const field of additionalInputFields) {
switch (field.type) {
case 'string':
data[field.name] = 'example'
break
case 'number':
data[field.name] = 42
break
case 'boolean':
data[field.name] = true
break
case 'object':
data[field.name] = { key: 'value' }
break
case 'array':
data[field.name] = [1, 2, 3]
break
default:
data[field.name] = 'example'
}
}
return data
}, [additionalInputFields])
const getJsonRpcPayload = useCallback((): Record<string, unknown> => {
const inputData = getExampleInputData()
const hasAdditionalData = Object.keys(inputData).length > 0
// Build parts array: TextPart for message text, DataPart for additional fields
const parts: Array<Record<string, unknown>> = [{ kind: 'text', text: 'Hello, agent!' }]
if (hasAdditionalData) {
parts.push({ kind: 'data', data: inputData })
}
return {
jsonrpc: '2.0',
id: '1',
method: useStreamingExample ? 'message/stream' : 'message/send',
params: {
message: {
role: 'user',
parts,
},
},
}
}, [getExampleInputData, useStreamingExample])
const getCurlCommand = useCallback((): string => {
if (!endpoint) return ''
const payload = getJsonRpcPayload()
const requiresAuth = authScheme !== 'none'
switch (language) {
case 'curl':
return requiresAuth
? `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
: `curl -X POST \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
case 'python':
return requiresAuth
? `import os
import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
print(response.json())`
: `import requests
response = requests.post(
"${endpoint}",
headers={"Content-Type": "application/json"},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
print(response.json())`
case 'javascript':
return requiresAuth
? `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data = await response.json();
console.log(data);`
: `const response = await fetch("${endpoint}", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data = await response.json();
console.log(data);`
case 'typescript':
return requiresAuth
? `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data: Record<string, unknown> = await response.json();
console.log(data);`
: `const response = await fetch("${endpoint}", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data: Record<string, unknown> = await response.json();
console.log(data);`
default:
return ''
}
}, [endpoint, language, getJsonRpcPayload, authScheme])
const handleCopyCommand = useCallback(() => {
navigator.clipboard.writeText(getCurlCommand())
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
}, [getCurlCommand])
if (isLoading) {
return (
<div className='-mx-1 space-y-[12px] px-1'>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[80px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
<Skeleton className='mt-[6.5px] h-[14px] w-[200px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[70px]' />
<Skeleton className='h-[80px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[90px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
</div>
)
}
return (
<form
id='a2a-deploy-form'
onSubmit={(e) => {
e.preventDefault()
handleCreateOrUpdate()
}}
className='-mx-1 space-y-[12px] overflow-y-auto px-1 pb-[16px]'
>
{/* Endpoint URL (shown when agent exists) */}
{existingAgent && endpoint && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
URL
</Label>
<div className='relative flex items-stretch overflow-hidden rounded-[4px] border border-[var(--border-1)]'>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-[6px] pl-[8px] font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-5)]'>
{baseUrl.replace(/^https?:\/\//, '')}/api/a2a/serve/
</div>
<div className='relative flex-1'>
<Input
value={existingAgent.id}
readOnly
className='rounded-none border-0 pr-[32px] pl-0 text-[var(--text-tertiary)] shadow-none'
/>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={() => {
navigator.clipboard.writeText(endpoint)
setUrlCopied(true)
setTimeout(() => setUrlCopied(false), 2000)
}}
className='-translate-y-1/2 absolute top-1/2 right-2'
>
{urlCopied ? (
<Check className='h-3 w-3 text-[var(--brand-tertiary-2)]' />
) : (
<Clipboard className='h-3 w-3 text-[var(--text-tertiary)]' />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{urlCopied ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
The A2A endpoint URL where clients can discover and call your agent
</p>
</div>
)}
{/* Agent Name */}
<div>
<Label
htmlFor='a2a-name'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Agent name <span className='text-red-500'>*</span>
</Label>
<Input
id='a2a-name'
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='Enter agent name'
required
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Human-readable name shown in the Agent Card
</p>
</div>
{/* Description */}
<div>
<Label
htmlFor='a2a-description'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Description <span className='text-red-500'>*</span>
</Label>
<Textarea
id='a2a-description'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder='Describe what this agent does...'
className='min-h-[80px] resize-none'
required
/>
</div>
{/* Authentication */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Authentication
</Label>
<Combobox
options={authSchemeOptions}
value={authScheme}
onChange={(v) => setAuthScheme(v as AuthScheme)}
placeholder='Select authentication...'
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{authScheme === 'none'
? 'Anyone can call this agent without authentication'
: 'Requires X-API-Key header or API key query parameter'}
</p>
</div>
{/* Capabilities */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Capabilities
</Label>
<div className='space-y-[8px]'>
<div className='flex items-center gap-[8px]'>
<Checkbox
id='a2a-push'
checked={pushNotificationsEnabled}
onCheckedChange={(checked) => setPushNotificationsEnabled(checked === true)}
/>
<label htmlFor='a2a-push' className='text-[13px] text-[var(--text-primary)]'>
Push notifications (webhooks)
</label>
</div>
</div>
</div>
{/* Tags */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Tags
</Label>
<TagInput
items={skillTags.map((tag) => ({ value: tag, isValid: true }))}
onAdd={(value) => {
if (!skillTags.includes(value)) {
setSkillTags((prev) => [...prev, value])
return true
}
return false
}}
onRemove={(_value, index) => {
setSkillTags((prev) => prev.filter((_, i) => i !== index))
}}
placeholder='Add tags'
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
/>
</div>
{/* Curl Preview (shown when agent exists) */}
{existingAgent && endpoint && (
<>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Language
</Label>
</div>
<ButtonGroup value={language} onValueChange={(val) => setLanguage(val as CodeLanguage)}>
{(Object.keys(LANGUAGE_LABELS) as CodeLanguage[]).map((lang) => (
<ButtonGroupItem key={lang} value={lang}>
{LANGUAGE_LABELS[lang]}
</ButtonGroupItem>
))}
</ButtonGroup>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Send message
</Label>
<div className='flex items-center gap-[8px]'>
<div className='flex items-center gap-[6px]'>
<Checkbox
id='a2a-stream-example'
checked={useStreamingExample}
onCheckedChange={(checked) => setUseStreamingExample(checked === true)}
/>
<label
htmlFor='a2a-stream-example'
className='text-[12px] text-[var(--text-secondary)]'
>
Stream
</label>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleCopyCommand}
aria-label='Copy command'
className='!p-1.5 -my-1.5'
>
{codeCopied ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{codeCopied ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<Code.Viewer
code={getCurlCommand()}
language={LANGUAGE_SYNTAX[language]}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
<div className='mt-[6.5px] flex items-start justify-between gap-2'>
<p className='text-[11px] text-[var(--text-secondary)]'>
External A2A clients can discover and call your agent. TextPart {' '}
<code className='text-[10px]'>&lt;start.input&gt;</code>, DataPart {' '}
<code className='text-[10px]'>&lt;start.data&gt;</code>, FilePart {' '}
<code className='text-[10px]'>&lt;start.files&gt;</code>.
</p>
{missingFields.any && (
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
)}
</div>
</div>
</>
)}
{/* Hidden triggers for modal footer */}
<button type='submit' data-a2a-save-trigger className='hidden' />
<button type='button' data-a2a-publish-trigger className='hidden' onClick={handlePublish} />
<button
type='button'
data-a2a-unpublish-trigger
className='hidden'
onClick={handleUnpublish}
/>
<button type='button' data-a2a-delete-trigger className='hidden' onClick={handleDelete} />
<button
type='button'
data-a2a-publish-new-trigger
className='hidden'
onClick={handlePublishNewAgent}
/>
<button
type='button'
data-a2a-update-republish-trigger
className='hidden'
onClick={handleUpdateAndRepublish}
/>
</form>
)
}

View File

@@ -125,12 +125,13 @@ export function ApiDeploy({
${endpoint}`
case 'python':
return `import requests
return `import os
import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": SIM_API_KEY,
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
@@ -142,7 +143,7 @@ print(response.json())`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
@@ -155,7 +156,7 @@ console.log(data);`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
@@ -183,12 +184,13 @@ console.log(data);`
${endpoint}`
case 'python':
return `import requests
return `import os
import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": SIM_API_KEY,
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')},
@@ -203,7 +205,7 @@ for line in response.iter_lines():
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
@@ -222,7 +224,7 @@ while (true) {
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
@@ -260,12 +262,13 @@ while (true) {
${endpoint}`
case 'python':
return `import requests
return `import os
import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": SIM_API_KEY,
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json",
"X-Execution-Mode": "async"
},
@@ -279,7 +282,7 @@ print(job) # Contains job_id for status checking`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json",
"X-Execution-Mode": "async"
},
@@ -293,7 +296,7 @@ console.log(job); // Contains job_id for status checking`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json",
"X-Execution-Mode": "async"
},
@@ -314,11 +317,12 @@ console.log(job); // Contains job_id for status checking`
${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION`
case 'python':
return `import requests
return `import os
import requests
response = requests.get(
"${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
headers={"X-API-Key": SIM_API_KEY}
headers={"X-API-Key": os.environ.get("SIM_API_KEY")}
)
status = response.json()
@@ -328,7 +332,7 @@ print(status)`
return `const response = await fetch(
"${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
{
headers: { "X-API-Key": SIM_API_KEY }
headers: { "X-API-Key": process.env.SIM_API_KEY }
}
);
@@ -339,7 +343,7 @@ console.log(status);`
return `const response = await fetch(
"${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
{
headers: { "X-API-Key": SIM_API_KEY }
headers: { "X-API-Key": process.env.SIM_API_KEY }
}
);
@@ -357,11 +361,12 @@ console.log(status);`
${baseUrl}/api/users/me/usage-limits`
case 'python':
return `import requests
return `import os
import requests
response = requests.get(
"${baseUrl}/api/users/me/usage-limits",
headers={"X-API-Key": SIM_API_KEY}
headers={"X-API-Key": os.environ.get("SIM_API_KEY")}
)
limits = response.json()
@@ -371,7 +376,7 @@ print(limits)`
return `const response = await fetch(
"${baseUrl}/api/users/me/usage-limits",
{
headers: { "X-API-Key": SIM_API_KEY }
headers: { "X-API-Key": process.env.SIM_API_KEY }
}
);
@@ -382,7 +387,7 @@ console.log(limits);`
return `const response = await fetch(
"${baseUrl}/api/users/me/usage-limits",
{
headers: { "X-API-Key": SIM_API_KEY }
headers: { "X-API-Key": process.env.SIM_API_KEY }
}
);

View File

@@ -513,25 +513,31 @@ export function McpDeploy({
{inputFormat.map((field) => (
<div
key={field.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
>
<div className='flex items-center justify-between'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>{field.name}</p>
<Badge variant='outline' className='text-[10px]'>
{field.type}
</Badge>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name}
</span>
<Badge size='sm'>{field.type}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input
value={parameterDescriptions[field.name] || ''}
onChange={(e) =>
setParameterDescriptions((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
placeholder={`Enter description for ${field.name}`}
/>
</div>
</div>
<Input
value={parameterDescriptions[field.name] || ''}
onChange={(e) =>
setParameterDescriptions((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
placeholder='Description'
className='mt-[6px] h-[28px] text-[12px]'
/>
</div>
))}
</div>
@@ -551,7 +557,6 @@ export function McpDeploy({
searchable
searchPlaceholder='Search servers...'
disabled={!toolName.trim() || isPending}
isLoading={isPending}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{selectedServersLabel}</span>
}

View File

@@ -12,9 +12,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
TagInput,
Textarea,
} from '@/components/emcn'
import { Skeleton, TagInput } from '@/components/ui'
import { Skeleton } 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'
@@ -404,10 +405,24 @@ export function TemplateDeploy({
Tags
</Label>
<TagInput
value={formData.tags}
onChange={(tags) => updateField('tags', tags)}
items={formData.tags.map((tag) => ({ value: tag, isValid: true }))}
onAdd={(value) => {
if (!formData.tags.includes(value) && formData.tags.length < 10) {
updateField('tags', [...formData.tags, value])
return true
}
return false
}}
onRemove={(_value, index) => {
updateField(
'tags',
formData.tags.filter((_, i) => i !== index)
)
}}
placeholder='Dev, Agents, Research, etc.'
maxTags={10}
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
disabled={isSubmitting}
/>
</div>

View File

@@ -27,6 +27,7 @@ import { useSettingsModalStore } from '@/stores/modals/settings/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { A2aDeploy } from './components/a2a/a2a'
import { ApiDeploy } from './components/api/api'
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
import { GeneralDeploy } from './components/general/general'
@@ -55,7 +56,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean
}
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form'
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form' | 'a2a'
export function DeployModal({
open,
@@ -96,6 +97,12 @@ export function DeployModal({
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [hasMcpServers, setHasMcpServers] = useState(false)
const [a2aSubmitting, setA2aSubmitting] = useState(false)
const [a2aCanSave, setA2aCanSave] = useState(false)
const [hasA2aAgent, setHasA2aAgent] = useState(false)
const [isA2aPublished, setIsA2aPublished] = useState(false)
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
const [showA2aDeleteConfirm, setShowA2aDeleteConfirm] = useState(false)
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
const [templateStatus, setTemplateStatus] = useState<{
status: 'pending' | 'approved' | 'rejected' | null
@@ -368,7 +375,6 @@ export function DeployModal({
async (version: number) => {
if (!workflowId) return
// Optimistically update versions to show the new active version immediately
const previousVersions = [...versions]
setVersions((prev) =>
prev.map((v) => ({
@@ -402,7 +408,6 @@ export function DeployModal({
setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
// Refresh deployed state in background (no loading flash)
refetchDeployedState()
fetchVersions()
@@ -423,7 +428,6 @@ export function DeployModal({
})
}
} catch (error) {
// Rollback optimistic update on error
setVersions(previousVersions)
throw error
}
@@ -578,6 +582,48 @@ export function DeployModal({
form?.requestSubmit()
}, [])
const handleA2aFormSubmit = useCallback(() => {
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
form?.requestSubmit()
}, [])
const handleA2aPublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
publishTrigger?.click()
}, [])
const handleA2aUnpublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const unpublishTrigger = form?.querySelector(
'[data-a2a-unpublish-trigger]'
) as HTMLButtonElement
unpublishTrigger?.click()
}, [])
const handleA2aPublishNew = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const publishNewTrigger = form?.querySelector(
'[data-a2a-publish-new-trigger]'
) as HTMLButtonElement
publishNewTrigger?.click()
}, [])
const handleA2aUpdateRepublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const updateRepublishTrigger = form?.querySelector(
'[data-a2a-update-republish-trigger]'
) as HTMLButtonElement
updateRepublishTrigger?.click()
}, [])
const handleA2aDelete = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const deleteTrigger = form?.querySelector('[data-a2a-delete-trigger]') as HTMLButtonElement
deleteTrigger?.click()
setShowA2aDeleteConfirm(false)
}, [])
const handleTemplateDelete = useCallback(() => {
const form = document.getElementById('template-deploy-form')
const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
@@ -610,6 +656,7 @@ export function DeployModal({
<ModalTabsTrigger value='general'>General</ModalTabsTrigger>
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
<ModalTabsTrigger value='mcp'>MCP</ModalTabsTrigger>
<ModalTabsTrigger value='a2a'>A2A</ModalTabsTrigger>
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
{/* <ModalTabsTrigger value='form'>Form</ModalTabsTrigger> */}
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
@@ -700,6 +747,24 @@ export function DeployModal({
/>
)}
</ModalTabsContent>
<ModalTabsContent value='a2a' className='h-full'>
{workflowId && (
<A2aDeploy
workflowId={workflowId}
workflowName={workflowMetadata?.name || 'Workflow'}
workflowDescription={workflowMetadata?.description}
isDeployed={isDeployed}
workflowNeedsRedeployment={needsRedeployment}
onSubmittingChange={setA2aSubmitting}
onCanSaveChange={setA2aCanSave}
onAgentExistsChange={setHasA2aAgent}
onPublishedChange={setIsA2aPublished}
onNeedsRepublishChange={setA2aNeedsRepublish}
onDeployWorkflow={onDeploy}
/>
)}
</ModalTabsContent>
</ModalBody>
</ModalTabs>
@@ -715,19 +780,23 @@ export function DeployModal({
/>
)}
{activeTab === 'api' && (
<ModalFooter className='items-center justify-end'>
<Button
variant='tertiary'
onClick={() => setIsCreateKeyModalOpen(true)}
disabled={createButtonDisabled}
>
Generate API Key
</Button>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button
variant='tertiary'
onClick={() => setIsCreateKeyModalOpen(true)}
disabled={createButtonDisabled}
>
Generate API Key
</Button>
</div>
</ModalFooter>
)}
{activeTab === 'chat' && (
<ModalFooter className='items-center'>
<div className='flex gap-2'>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
{chatExists && (
<Button
type='button'
@@ -760,8 +829,9 @@ export function DeployModal({
</ModalFooter>
)}
{activeTab === 'mcp' && isDeployed && hasMcpServers && (
<ModalFooter className='items-center'>
<div className='flex gap-2'>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button
type='button'
variant='default'
@@ -781,17 +851,17 @@ export function DeployModal({
</ModalFooter>
)}
{activeTab === 'template' && (
<ModalFooter
className={`items-center ${hasExistingTemplate && templateStatus ? 'justify-between' : ''}`}
>
{hasExistingTemplate && templateStatus && (
<ModalFooter className='items-center justify-between'>
{hasExistingTemplate && templateStatus ? (
<TemplateStatusBadge
status={templateStatus.status}
views={templateStatus.views}
stars={templateStatus.stars}
/>
) : (
<div />
)}
<div className='flex gap-2'>
<div className='flex items-center gap-2'>
{hasExistingTemplate && (
<Button
type='button'
@@ -820,8 +890,9 @@ export function DeployModal({
</ModalFooter>
)}
{/* {activeTab === 'form' && (
<ModalFooter className='items-center'>
<div className='flex gap-2'>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
{formExists && (
<Button
type='button'
@@ -853,6 +924,81 @@ export function DeployModal({
</div>
</ModalFooter>
)} */}
{activeTab === 'a2a' && (
<ModalFooter className='items-center justify-between'>
{/* Status badge on left */}
{hasA2aAgent ? (
isA2aPublished ? (
<Badge variant={a2aNeedsRepublish ? 'amber' : 'green'} size='lg' dot>
{a2aNeedsRepublish ? 'Update deployment' : 'Live'}
</Badge>
) : (
<Badge variant='red' size='lg' dot>
Unpublished
</Badge>
)
) : (
<div />
)}
<div className='flex items-center gap-2'>
{/* No agent exists: Show "Publish Agent" button */}
{!hasA2aAgent && (
<Button
type='button'
variant='tertiary'
onClick={handleA2aPublishNew}
disabled={a2aSubmitting || !a2aCanSave}
>
{a2aSubmitting ? 'Publishing...' : 'Publish Agent'}
</Button>
)}
{/* Agent exists and published: Show Unpublish and Update */}
{hasA2aAgent && isA2aPublished && (
<>
<Button
type='button'
variant='default'
onClick={handleA2aUnpublish}
disabled={a2aSubmitting}
>
Unpublish
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleA2aUpdateRepublish}
disabled={a2aSubmitting || !a2aCanSave || !a2aNeedsRepublish}
>
{a2aSubmitting ? 'Updating...' : 'Update'}
</Button>
</>
)}
{/* Agent exists but unpublished: Show Delete and Publish */}
{hasA2aAgent && !isA2aPublished && (
<>
<Button
type='button'
variant='default'
onClick={() => setShowA2aDeleteConfirm(true)}
disabled={a2aSubmitting}
>
Delete
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleA2aPublish}
disabled={a2aSubmitting || !a2aCanSave}
>
{a2aSubmitting ? 'Publishing...' : 'Publish'}
</Button>
</>
)}
</div>
</ModalFooter>
)}
</ModalContent>
</Modal>
@@ -882,6 +1028,32 @@ export function DeployModal({
</ModalContent>
</Modal>
<Modal open={showA2aDeleteConfirm} onOpenChange={setShowA2aDeleteConfirm}>
<ModalContent size='sm'>
<ModalHeader>Delete A2A Agent</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete this agent?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove the agent configuration.
</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => setShowA2aDeleteConfirm(false)}
disabled={a2aSubmitting}
>
Cancel
</Button>
<Button variant='destructive' onClick={handleA2aDelete} disabled={a2aSubmitting}>
{a2aSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<CreateApiKeyModal
open={isCreateKeyModalOpen}
onOpenChange={setIsCreateKeyModalOpen}
@@ -952,10 +1124,13 @@ function GeneralFooter({
}: GeneralFooterProps) {
if (!isDeployed) {
return (
<ModalFooter>
<Button variant='tertiary' onClick={onDeploy} disabled={isSubmitting}>
{isSubmitting ? 'Deploying...' : 'Deploy'}
</Button>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button variant='tertiary' onClick={onDeploy} disabled={isSubmitting}>
{isSubmitting ? 'Deploying...' : 'Deploy'}
</Button>
</div>
</ModalFooter>
)
}

View File

@@ -2319,6 +2319,8 @@ const WorkflowContent = React.memo(() => {
/**
* Handles connection drag end. Detects if the edge was dropped over a block
* and automatically creates a connection to that block's target handle.
* Only creates a connection if ReactFlow didn't already handle it (e.g., when
* dropping on the block body instead of a handle).
*/
const onConnectEnd = useCallback(
(event: MouseEvent | TouchEvent) => {
@@ -2340,14 +2342,25 @@ const WorkflowContent = React.memo(() => {
// Find node under cursor
const targetNode = findNodeAtPosition(flowPosition)
// Create connection if valid target found
// Create connection if valid target found AND edge doesn't already exist
// ReactFlow's onConnect fires first when dropping on a handle, so we check
// if that connection already exists to avoid creating duplicates.
// IMPORTANT: We must read directly from the store (not React state) because
// the store update from ReactFlow's onConnect may not have triggered a
// React re-render yet when this callback runs (typically 1-2ms later).
if (targetNode && targetNode.id !== source.nodeId) {
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
const currentEdges = useWorkflowStore.getState().edges
const edgeAlreadyExists = currentEdges.some(
(e) => e.source === source.nodeId && e.target === targetNode.id
)
if (!edgeAlreadyExists) {
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
}
}
connectionSourceRef.current = null

View File

@@ -36,8 +36,6 @@ interface FolderItemProps {
onDragEnter?: (e: React.DragEvent<HTMLElement>) => void
onDragLeave?: (e: React.DragEvent<HTMLElement>) => void
}
onDragStart?: () => void
onDragEnd?: () => void
}
/**
@@ -48,13 +46,7 @@ interface FolderItemProps {
* @param props - Component props
* @returns Folder item with drag and expand support
*/
export function FolderItem({
folder,
level,
hoverHandlers,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
}: FolderItemProps) {
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
@@ -80,7 +72,6 @@ export function FolderItem({
})
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
workspaceId,
folderId: folder.id,
})
@@ -144,6 +135,11 @@ export function FolderItem({
}
}, [createFolderMutation, workspaceId, folder.id, expandFolder])
/**
* Drag start handler - sets folder data for drag operation
*
* @param e - React drag event
*/
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditing) {
@@ -153,25 +149,14 @@ export function FolderItem({
e.dataTransfer.setData('folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
onDragStartProp?.()
},
[folder.id, onDragStartProp]
[folder.id]
)
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
const {
isOpen: isContextMenuOpen,
position,

View File

@@ -29,8 +29,6 @@ interface WorkflowItemProps {
active: boolean
level: number
onWorkflowClick: (workflowId: string, shiftKey: boolean, metaKey: boolean) => void
onDragStart?: () => void
onDragEnd?: () => void
}
/**
@@ -40,14 +38,7 @@ interface WorkflowItemProps {
* @param props - Component props
* @returns Workflow item with drag and selection support
*/
export function WorkflowItem({
workflow,
active,
level,
onWorkflowClick,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
}: WorkflowItemProps) {
export function WorkflowItem({ workflow, active, level, onWorkflowClick }: WorkflowItemProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { selectedWorkflows } = useFolderStore()
@@ -89,7 +80,7 @@ export function WorkflowItem({
const { handleDuplicateWorkflow: duplicateWorkflow } = useDuplicateWorkflow({ workspaceId })
const { handleExportWorkflow: exportWorkflow } = useExportWorkflow({ workspaceId })
const { handleExportWorkflow: exportWorkflow } = useExportWorkflow()
const handleDuplicateWorkflow = useCallback(() => {
const workflowIds = capturedSelectionRef.current?.workflowIds || []
if (workflowIds.length === 0) return
@@ -113,6 +104,11 @@ export function WorkflowItem({
[workflow.id, updateWorkflow]
)
/**
* Drag start handler - handles workflow dragging with multi-selection support
*
* @param e - React drag event
*/
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditing) {
@@ -125,25 +121,14 @@ export function WorkflowItem({
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
e.dataTransfer.effectAllowed = 'move'
onDragStartProp?.()
},
[isSelected, selectedWorkflows, workflow.id, onDragStartProp]
[isSelected, selectedWorkflows, workflow.id]
)
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
const {
isOpen: isContextMenuOpen,
position,

View File

@@ -14,6 +14,9 @@ import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/**
* Constants for tree layout and styling
*/
const TREE_SPACING = {
INDENT_PER_LEVEL: 20,
} as const
@@ -26,18 +29,12 @@ interface WorkflowListProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
function DropIndicatorLine({ show, level = 0 }: { show: boolean; level?: number }) {
if (!show) return null
return (
<div
className='pointer-events-none absolute left-0 right-0 z-20 flex items-center'
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
>
<div className='h-[2px] flex-1 rounded-full bg-[#0096FF]' />
</div>
)
}
/**
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
*
* @param props - Component props
* @returns Workflow list with folders and drag-drop support
*/
export function WorkflowList({
regularWorkflows,
isLoading = false,
@@ -51,20 +48,20 @@ export function WorkflowList({
const workflowId = params.workflowId as string
const { isLoading: foldersLoading } = useFolders(workspaceId)
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
const {
dropIndicator,
dropTargetId,
isDragging,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createEmptyFolderDropZone,
createRootDropZone,
handleDragStart,
handleDragEnd,
createItemDragHandlers,
createRootDragHandlers,
createFolderHeaderHoverHandlers,
} = useDragDrop()
// Set scroll container when ref changes
useEffect(() => {
if (scrollContainerRef.current) {
setScrollContainer(scrollContainerRef.current)
@@ -79,22 +76,23 @@ export function WorkflowList({
return activeWorkflow?.folderId || null
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
const workflowsByFolder = useMemo(() => {
const grouped = regularWorkflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
)
for (const folderId of Object.keys(grouped)) {
grouped[folderId].sort((a, b) => a.sortOrder - b.sortOrder)
}
return grouped
}, [regularWorkflows])
const workflowsByFolder = useMemo(
() =>
regularWorkflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
),
[regularWorkflows]
)
/**
* Build a flat list of all workflow IDs in display order for range selection
*/
const orderedWorkflowIds = useMemo(() => {
const ids: string[] = []
@@ -108,10 +106,12 @@ export function WorkflowList({
}
}
// Collect from folders first
for (const folder of folderTree) {
collectWorkflowIds(folder)
}
// Then collect root workflows
const rootWorkflows = workflowsByFolder.root || []
for (const workflow of rootWorkflows) {
ids.push(workflow.id)
@@ -120,24 +120,30 @@ export function WorkflowList({
return ids
}, [folderTree, workflowsByFolder])
// Workflow selection hook - uses active workflow ID as anchor for range selection
const { handleWorkflowClick } = useWorkflowSelection({
workflowIds: orderedWorkflowIds,
activeWorkflowId: workflowId,
})
const isWorkflowActive = useCallback(
(wfId: string) => pathname === `/workspace/${workspaceId}/w/${wfId}`,
(workflowId: string) => pathname === `/workspace/${workspaceId}/w/${workflowId}`,
[pathname, workspaceId]
)
/**
* Auto-expand folders and select active workflow.
*/
useEffect(() => {
if (!workflowId || isLoading || foldersLoading) return
// Expand folder path to reveal workflow
if (activeWorkflowFolderId) {
const folderPath = getFolderPath(activeWorkflowFolderId)
folderPath.forEach((folder) => setExpanded(folder.id, true))
}
// Select workflow if not already selected
const { selectedWorkflows, selectOnly } = useFolderStore.getState()
if (!selectedWorkflows.has(workflowId)) {
selectOnly(workflowId)
@@ -145,40 +151,23 @@ export function WorkflowList({
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded])
const renderWorkflowItem = useCallback(
(workflow: WorkflowMetadata, level: number, folderId: string | null = null) => {
const showBefore =
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'before'
const showAfter =
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'after'
return (
<div key={workflow.id} className='relative'>
<DropIndicatorLine show={showBefore} level={level} />
<div
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createWorkflowDragHandlers(workflow.id, folderId)}
>
<WorkflowItem
workflow={workflow}
active={isWorkflowActive(workflow.id)}
level={level}
onWorkflowClick={handleWorkflowClick}
onDragStart={() => handleDragStart('workflow')}
onDragEnd={handleDragEnd}
/>
</div>
<DropIndicatorLine show={showAfter} level={level} />
(workflow: WorkflowMetadata, level: number, parentFolderId: string | null = null) => (
<div key={workflow.id} className='relative' {...createItemDragHandlers(parentFolderId)}>
<div
style={{
paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px`,
}}
>
<WorkflowItem
workflow={workflow}
active={isWorkflowActive(workflow.id)}
level={level}
onWorkflowClick={handleWorkflowClick}
/>
</div>
)
},
[
dropIndicator,
isWorkflowActive,
createWorkflowDragHandlers,
handleWorkflowClick,
handleDragStart,
handleDragEnd,
]
</div>
),
[isWorkflowActive, createItemDragHandlers, handleWorkflowClick]
)
const renderFolderSection = useCallback(
@@ -190,72 +179,45 @@ export function WorkflowList({
const workflowsInFolder = workflowsByFolder[folder.id] || []
const isExpanded = expandedFolders.has(folder.id)
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
const showBefore =
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'before'
const showAfter = dropIndicator?.targetId === folder.id && dropIndicator?.position === 'after'
const showInside =
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'inside'
const childItems: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const childFolder of folder.children) {
childItems.push({
type: 'folder',
id: childFolder.id,
sortOrder: childFolder.sortOrder,
data: childFolder,
})
}
for (const workflow of workflowsInFolder) {
childItems.push({
type: 'workflow',
id: workflow.id,
sortOrder: workflow.sortOrder,
data: workflow,
})
}
childItems.sort((a, b) => a.sortOrder - b.sortOrder)
const isDropTarget = dropTargetId === folder.id
return (
<div key={folder.id} className='relative'>
<DropIndicatorLine show={showBefore} level={level} />
<div key={folder.id} className='relative' {...createFolderDragHandlers(folder.id)}>
{/* Drop target highlight overlay - always rendered for stable DOM */}
<div
className={clsx(
'rounded-[4px] transition-colors duration-75',
showInside && isDragging && 'bg-[#0096FF]/10'
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
isDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
)}
/>
<div
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createFolderDragHandlers(folder.id, parentFolderId)}
{...createItemDragHandlers(folder.id)}
>
<FolderItem
folder={folder}
level={level}
onDragStart={() => handleDragStart('folder')}
onDragEnd={handleDragEnd}
hoverHandlers={createFolderHeaderHoverHandlers(folder.id)}
/>
</div>
<DropIndicatorLine show={showAfter} level={level} />
{isExpanded && (
<div className='relative'>
{isExpanded && hasChildren && (
<div className='relative' {...createItemDragHandlers(folder.id)}>
{/* Vertical line - positioned to align under folder chevron */}
<div
className='pointer-events-none absolute top-0 bottom-0 w-px bg-[var(--border)]'
style={{ left: `${level * TREE_SPACING.INDENT_PER_LEVEL + 12}px` }}
/>
<div className='mt-[2px] space-y-[2px] pl-[2px]'>
{childItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, level + 1, folder.id)
: renderWorkflowItem(item.data as WorkflowMetadata, level + 1, folder.id)
)}
{!hasChildren && (
<div className='h-[24px]' {...createEmptyFolderDropZone(folder.id)} />
{workflowsInFolder.map((workflow: WorkflowMetadata) =>
renderWorkflowItem(workflow, level + 1, folder.id)
)}
{folder.children.map((childFolder) => (
<div key={childFolder.id} className='relative'>
{renderFolderSection(childFolder, level + 1, folder.id)}
</div>
))}
</div>
</div>
)}
@@ -265,46 +227,29 @@ export function WorkflowList({
[
workflowsByFolder,
expandedFolders,
dropIndicator,
dropTargetId,
isDragging,
createFolderDragHandlers,
createEmptyFolderDropZone,
handleDragStart,
handleDragEnd,
createItemDragHandlers,
createFolderHeaderHoverHandlers,
renderWorkflowItem,
]
)
const rootDropZoneHandlers = createRootDropZone()
const handleRootDragEvents = createRootDragHandlers()
const rootWorkflows = workflowsByFolder.root || []
const isRootDropTarget = dropTargetId === 'root'
const hasRootWorkflows = rootWorkflows.length > 0
const hasFolders = folderTree.length > 0
const rootItems = useMemo(() => {
const items: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const folder of folderTree) {
items.push({ type: 'folder', id: folder.id, sortOrder: folder.sortOrder, data: folder })
}
for (const workflow of rootWorkflows) {
items.push({
type: 'workflow',
id: workflow.id,
sortOrder: workflow.sortOrder,
data: workflow,
})
}
return items.sort((a, b) => a.sortOrder - b.sortOrder)
}, [folderTree, rootWorkflows])
const hasRootItems = rootItems.length > 0
const showRootInside = dropIndicator?.targetId === 'root' && dropIndicator?.position === 'inside'
/**
* Handle click on empty space to revert to active workflow selection
*/
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only handle clicks directly on the container (empty space)
if (e.target !== e.currentTarget) return
const { selectOnly, clearSelection } = useFolderStore.getState()
workflowId ? selectOnly(workflowId) : clearSelection()
},
@@ -313,20 +258,36 @@ export function WorkflowList({
return (
<div className='flex min-h-full flex-col pb-[8px]' onClick={handleContainerClick}>
{/* Folders Section */}
{hasFolders && (
<div className='mb-[2px] space-y-[2px]'>
{folderTree.map((folder) => renderFolderSection(folder, 0))}
</div>
)}
{/* Root Workflows Section - Expands to fill remaining space */}
<div
className={clsx(
'relative flex-1 rounded-[4px] transition-colors duration-75',
!hasRootItems && 'min-h-[26px]',
showRootInside && isDragging && 'bg-[#0096FF]/10'
)}
{...rootDropZoneHandlers}
className={clsx('relative flex-1', !hasRootWorkflows && 'min-h-[26px]')}
{...handleRootDragEvents}
>
<div className='space-y-[2px]'>
{rootItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, 0, null)
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
{/* Root drop target highlight overlay - always rendered for stable DOM */}
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
isRootDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
)}
/>
<div className='space-y-[2px]'>
{rootWorkflows.map((workflow: WorkflowMetadata) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={isWorkflowActive(workflow.id)}
level={0}
onWorkflowClick={handleWorkflowClick}
/>
))}
</div>
</div>

View File

@@ -1,6 +1,6 @@
export { useAutoScroll } from './use-auto-scroll'
export { useContextMenu } from './use-context-menu'
export { type DropIndicator, useDragDrop } from './use-drag-drop'
export { useDragDrop } from './use-drag-drop'
export { useFolderExpand } from './use-folder-expand'
export { useFolderOperations } from './use-folder-operations'
export { useItemDrag } from './use-item-drag'

View File

@@ -1,39 +1,47 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useReorderFolders } from '@/hooks/queries/folders'
import { useReorderWorkflows } from '@/hooks/queries/workflows'
import { useUpdateFolder } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowList:DragDrop')
const SCROLL_THRESHOLD = 60
const SCROLL_SPEED = 8
const HOVER_EXPAND_DELAY = 400
/**
* Constants for auto-scroll behavior
*/
const SCROLL_THRESHOLD = 60 // Distance from edge to trigger scroll
const SCROLL_SPEED = 8 // Pixels per frame
export interface DropIndicator {
targetId: string
position: 'before' | 'after' | 'inside'
folderId: string | null
}
/**
* Constants for folder auto-expand on hover during drag
*/
const HOVER_EXPAND_DELAY = 400 // Milliseconds to wait before expanding folder
/**
* Custom hook for handling drag and drop operations for workflows and folders.
* Includes auto-scrolling, drop target highlighting, and hover-to-expand.
*
* @returns Drag and drop state and event handlers
*/
export function useDragDrop() {
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null)
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [hoverFolderId, setHoverFolderId] = useState<string | null>(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const scrollIntervalRef = useRef<number | null>(null)
const hoverExpandTimerRef = useRef<number | null>(null)
const lastDragYRef = useRef<number>(0)
const draggedTypeRef = useRef<'workflow' | 'folder' | null>(null)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const reorderWorkflowsMutation = useReorderWorkflows()
const reorderFoldersMutation = useReorderFolders()
const updateFolderMutation = useUpdateFolder()
const { setExpanded, expandedFolders } = useFolderStore()
const { updateWorkflow } = useWorkflowRegistry()
/**
* Auto-scroll handler - scrolls container when dragging near edges
*/
const handleAutoScroll = useCallback(() => {
if (!scrollContainerRef.current || !isDragging) return
@@ -41,17 +49,22 @@ export function useDragDrop() {
const rect = container.getBoundingClientRect()
const mouseY = lastDragYRef.current
// Only scroll if mouse is within container bounds
if (mouseY < rect.top || mouseY > rect.bottom) return
// Calculate distance from top and bottom edges
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollDelta = 0
// Scroll up if near top and not at scroll top
if (distanceFromTop < SCROLL_THRESHOLD && container.scrollTop > 0) {
const intensity = Math.max(0, Math.min(1, 1 - distanceFromTop / SCROLL_THRESHOLD))
scrollDelta = -SCROLL_SPEED * intensity
} else if (distanceFromBottom < SCROLL_THRESHOLD) {
}
// Scroll down if near bottom and not at scroll bottom
else if (distanceFromBottom < SCROLL_THRESHOLD) {
const maxScroll = container.scrollHeight - container.clientHeight
if (container.scrollTop < maxScroll) {
const intensity = Math.max(0, Math.min(1, 1 - distanceFromBottom / SCROLL_THRESHOLD))
@@ -64,9 +77,12 @@ export function useDragDrop() {
}
}, [isDragging])
/**
* Start auto-scroll animation loop
*/
useEffect(() => {
if (isDragging) {
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10)
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10) // ~100fps for smoother response
} else {
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current)
@@ -81,17 +97,30 @@ export function useDragDrop() {
}
}, [isDragging, handleAutoScroll])
/**
* Handle hover folder changes - start/clear expand timer
*/
useEffect(() => {
// Clear existing timer when hover folder changes
if (hoverExpandTimerRef.current) {
clearTimeout(hoverExpandTimerRef.current)
hoverExpandTimerRef.current = null
}
if (!isDragging || !hoverFolderId) return
if (expandedFolders.has(hoverFolderId)) return
// Don't start timer if not dragging or no folder is hovered
if (!isDragging || !hoverFolderId) {
return
}
// Don't expand if folder is already expanded
if (expandedFolders.has(hoverFolderId)) {
return
}
// Start timer to expand folder after delay
hoverExpandTimerRef.current = window.setTimeout(() => {
setExpanded(hoverFolderId, true)
logger.info(`Auto-expanded folder ${hoverFolderId} during drag`)
}, HOVER_EXPAND_DELAY)
return () => {
@@ -102,333 +131,249 @@ export function useDragDrop() {
}
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
/**
* Cleanup hover state when dragging stops
*/
useEffect(() => {
if (!isDragging) {
setHoverFolderId(null)
setDropIndicator(null)
draggedTypeRef.current = null
}
}, [isDragging])
const calculateDropPosition = useCallback(
(e: React.DragEvent, element: HTMLElement): 'before' | 'after' => {
const rect = element.getBoundingClientRect()
const midY = rect.top + rect.height / 2
return e.clientY < midY ? 'before' : 'after'
},
[]
)
/**
* Moves one or more workflows to a target folder
*
* @param workflowIds - Array of workflow IDs to move
* @param targetFolderId - Target folder ID or null for root
*/
const handleWorkflowDrop = useCallback(
async (workflowIds: string[], indicator: DropIndicator) => {
if (!workflowIds.length || !workspaceId) return
async (workflowIds: string[], targetFolderId: string | null) => {
if (!workflowIds.length) {
logger.warn('No workflows to move')
return
}
try {
const destinationFolderId =
indicator.position === 'inside'
? indicator.targetId === 'root'
? null
: indicator.targetId
: indicator.folderId
type SiblingItem = { type: 'folder' | 'workflow'; id: string; sortOrder: number }
const currentFolders = useFolderStore.getState().folders
const currentWorkflows = useWorkflowRegistry.getState().workflows
const siblingFolders = Object.values(currentFolders).filter(
(f) => f.parentId === destinationFolderId
await Promise.all(
workflowIds.map((workflowId) => updateWorkflow(workflowId, { folderId: targetFolderId }))
)
const siblingWorkflows = Object.values(currentWorkflows).filter(
(w) => w.folderId === destinationFolderId
)
const siblingItems: SiblingItem[] = [
...siblingFolders.map((f) => ({
type: 'folder' as const,
id: f.id,
sortOrder: f.sortOrder,
})),
...siblingWorkflows.map((w) => ({
type: 'workflow' as const,
id: w.id,
sortOrder: w.sortOrder,
})),
].sort((a, b) => a.sortOrder - b.sortOrder)
const movingSet = new Set(workflowIds)
const remaining = siblingItems.filter(
(item) => !(item.type === 'workflow' && movingSet.has(item.id))
)
const moving = workflowIds.map((id) => ({ type: 'workflow' as const, id, sortOrder: 0 }))
let insertAt: number
if (indicator.position === 'inside') {
insertAt = remaining.length
} else {
const targetIdx = remaining.findIndex((item) => item.id === indicator.targetId)
insertAt = indicator.position === 'before' ? targetIdx : targetIdx + 1
}
const newOrder: SiblingItem[] = [
...remaining.slice(0, insertAt),
...moving,
...remaining.slice(insertAt),
]
const folderUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'folder')
.map((item) => ({
id: item.id,
sortOrder: item.sortOrder,
parentId: destinationFolderId,
}))
const workflowUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'workflow')
.map((item) => ({
id: item.id,
sortOrder: item.sortOrder,
folderId: destinationFolderId,
}))
await Promise.all([
folderUpdates.length > 0 &&
reorderFoldersMutation.mutateAsync({ workspaceId, updates: folderUpdates }),
workflowUpdates.length > 0 &&
reorderWorkflowsMutation.mutateAsync({ workspaceId, updates: workflowUpdates }),
])
logger.info(`Moved ${workflowIds.length} workflow(s)`)
} catch (error) {
logger.error('Failed to reorder workflows:', error)
logger.error('Failed to move workflows:', error)
}
},
[workspaceId, reorderFoldersMutation, reorderWorkflowsMutation]
[updateWorkflow]
)
const handleFolderDrop = useCallback(
async (draggedFolderId: string, indicator: DropIndicator) => {
if (!draggedFolderId || !workspaceId) return
/**
* Moves a folder to a new parent folder, with validation
*
* @param draggedFolderId - ID of the folder being moved
* @param targetFolderId - Target folder ID or null for root
*/
const handleFolderMove = useCallback(
async (draggedFolderId: string, targetFolderId: string | null) => {
if (!draggedFolderId) {
logger.warn('No folder to move')
return
}
try {
const folderStore = useFolderStore.getState()
const currentFolders = folderStore.folders
const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
const targetParentId =
indicator.position === 'inside'
? indicator.targetId === 'root'
? null
: indicator.targetId
: indicator.folderId
// Prevent moving folder into its own descendant
if (
targetFolderId &&
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
) {
logger.info('Cannot move folder into its own descendant')
return
}
if (draggedFolderId === targetParentId) {
// Prevent moving folder into itself
if (draggedFolderId === targetFolderId) {
logger.info('Cannot move folder into itself')
return
}
if (targetParentId) {
const targetPath = folderStore.getFolderPath(targetParentId)
if (targetPath.some((f) => f.id === draggedFolderId)) {
logger.info('Cannot move folder into its own descendant')
return
}
if (!workspaceId) {
logger.warn('No workspaceId available for folder move')
return
}
type SiblingItem = { type: 'folder' | 'workflow'; id: string; sortOrder: number }
const currentWorkflows = useWorkflowRegistry.getState().workflows
const siblingFolders = Object.values(currentFolders).filter(
(f) => f.parentId === targetParentId
)
const siblingWorkflows = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetParentId
)
const siblingItems: SiblingItem[] = [
...siblingFolders.map((f) => ({
type: 'folder' as const,
id: f.id,
sortOrder: f.sortOrder,
})),
...siblingWorkflows.map((w) => ({
type: 'workflow' as const,
id: w.id,
sortOrder: w.sortOrder,
})),
].sort((a, b) => a.sortOrder - b.sortOrder)
const remaining = siblingItems.filter(
(item) => !(item.type === 'folder' && item.id === draggedFolderId)
)
let insertAt: number
if (indicator.position === 'inside') {
insertAt = remaining.length
} else {
const targetIdx = remaining.findIndex((item) => item.id === indicator.targetId)
insertAt = indicator.position === 'before' ? targetIdx : targetIdx + 1
}
const newOrder: SiblingItem[] = [
...remaining.slice(0, insertAt),
{ type: 'folder', id: draggedFolderId, sortOrder: 0 },
...remaining.slice(insertAt),
]
const folderUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'folder')
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, parentId: targetParentId }))
const workflowUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'workflow')
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, folderId: targetParentId }))
await Promise.all([
folderUpdates.length > 0 &&
reorderFoldersMutation.mutateAsync({ workspaceId, updates: folderUpdates }),
workflowUpdates.length > 0 &&
reorderWorkflowsMutation.mutateAsync({ workspaceId, updates: workflowUpdates }),
])
await updateFolderMutation.mutateAsync({
workspaceId,
id: draggedFolderId,
updates: { parentId: targetFolderId },
})
logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
} catch (error) {
logger.error('Failed to reorder folder:', error)
logger.error('Failed to move folder:', error)
}
},
[workspaceId, reorderFoldersMutation, reorderWorkflowsMutation]
[updateFolderMutation, workspaceId]
)
const handleDrop = useCallback(
async (e: React.DragEvent) => {
/**
* Handles drop events for both workflows and folders
*
* @param e - React drag event
* @param targetFolderId - Target folder ID or null for root
*/
const handleFolderDrop = useCallback(
async (e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault()
e.stopPropagation()
const indicator = dropIndicator
setDropIndicator(null)
setDropTargetId(null)
setIsDragging(false)
if (!indicator) return
try {
// Check if dropping workflows
const workflowIdsData = e.dataTransfer.getData('workflow-ids')
if (workflowIdsData) {
const workflowIds = JSON.parse(workflowIdsData) as string[]
await handleWorkflowDrop(workflowIds, indicator)
await handleWorkflowDrop(workflowIds, targetFolderId)
return
}
// Check if dropping a folder
const folderIdData = e.dataTransfer.getData('folder-id')
if (folderIdData) {
await handleFolderDrop(folderIdData, indicator)
if (folderIdData && targetFolderId !== folderIdData) {
await handleFolderMove(folderIdData, targetFolderId)
}
} catch (error) {
logger.error('Failed to handle drop:', error)
}
},
[dropIndicator, handleWorkflowDrop, handleFolderDrop]
)
const createWorkflowDragHandlers = useCallback(
(workflowId: string, folderId: string | null) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
lastDragYRef.current = e.clientY
setIsDragging(true)
const position = calculateDropPosition(e, e.currentTarget)
setDropIndicator({ targetId: workflowId, position, folderId })
},
onDrop: handleDrop,
}),
[calculateDropPosition, handleDrop]
[handleWorkflowDrop, handleFolderMove]
)
/**
* Creates drag event handlers for a specific folder section
* These handlers are attached to the entire folder section container
*
* @param folderId - Folder ID to create handlers for
* @returns Object containing drag event handlers
*/
const createFolderDragHandlers = useCallback(
(folderId: string, parentFolderId: string | null) => ({
(folderId: string) => ({
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
setIsDragging(true)
},
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
lastDragYRef.current = e.clientY
setDropTargetId(folderId)
setIsDragging(true)
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
// Only clear if we're leaving the folder section completely
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setDropTargetId(null)
}
},
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, folderId),
}),
[handleFolderDrop]
)
/**
* Creates drag event handlers for items (workflows/folders) that belong to a parent folder
* When dragging over an item, highlights the parent folder section
*
* @param parentFolderId - Parent folder ID or null for root
* @returns Object containing drag event handlers
*/
const createItemDragHandlers = useCallback(
(parentFolderId: string | null) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
lastDragYRef.current = e.clientY
setDropTargetId(parentFolderId || 'root')
setIsDragging(true)
},
}),
[]
)
if (draggedTypeRef.current === 'folder') {
const position = calculateDropPosition(e, e.currentTarget)
setDropIndicator({ targetId: folderId, position, folderId: parentFolderId })
} else {
setDropIndicator({ targetId: folderId, position: 'inside', folderId: parentFolderId })
/**
* Creates drag event handlers for the root drop zone
*
* @returns Object containing drag event handlers for root
*/
const createRootDragHandlers = useCallback(
() => ({
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
setIsDragging(true)
},
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
lastDragYRef.current = e.clientY
setDropTargetId('root')
setIsDragging(true)
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
// Only clear if we're leaving the root completely
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setDropTargetId(null)
}
},
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, null),
}),
[handleFolderDrop]
)
/**
* Creates drag event handlers for folder header (the clickable part)
* These handlers trigger folder expansion on hover during drag
*
* @param folderId - Folder ID to handle hover for
* @returns Object containing drag event handlers for folder header
*/
const createFolderHeaderHoverHandlers = useCallback(
(folderId: string) => ({
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
if (isDragging) {
setHoverFolderId(folderId)
}
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
// Only clear if we're leaving the folder header completely
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setHoverFolderId(null)
}
},
onDrop: handleDrop,
}),
[calculateDropPosition, handleDrop]
[isDragging]
)
const createEmptyFolderDropZone = useCallback(
(folderId: string) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
lastDragYRef.current = e.clientY
setIsDragging(true)
setDropIndicator({ targetId: folderId, position: 'inside', folderId })
},
onDrop: handleDrop,
}),
[handleDrop]
)
const createRootDropZone = useCallback(
() => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
lastDragYRef.current = e.clientY
setIsDragging(true)
setDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setDropIndicator(null)
}
},
onDrop: handleDrop,
}),
[handleDrop]
)
const handleDragStart = useCallback((type: 'workflow' | 'folder') => {
draggedTypeRef.current = type
setIsDragging(true)
}, [])
const handleDragEnd = useCallback(() => {
setIsDragging(false)
setDropIndicator(null)
draggedTypeRef.current = null
setHoverFolderId(null)
}, [])
/**
* Set the scroll container ref for auto-scrolling
*
* @param element - Scrollable container element
*/
const setScrollContainer = useCallback((element: HTMLDivElement | null) => {
scrollContainerRef.current = element
}, [])
return {
dropIndicator,
dropTargetId,
isDragging,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createEmptyFolderDropZone,
createRootDropZone,
handleDragStart,
handleDragEnd,
createItemDragHandlers,
createRootDragHandlers,
createFolderHeaderHoverHandlers,
}
}

View File

@@ -1,20 +1,21 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
import {
downloadFile,
exportFolderToZip,
type FolderExportData,
fetchWorkflowForExport,
sanitizePathSegment,
type WorkflowExportData,
} from '@/lib/workflows/operations/import-export'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import type { Variable } from '@/stores/workflows/workflow/types'
const logger = createLogger('useExportFolder')
interface UseExportFolderProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* The folder ID to export
*/
@@ -25,85 +26,80 @@ interface UseExportFolderProps {
onSuccess?: () => void
}
interface CollectedWorkflow {
id: string
folderId: string | null
}
/**
* Recursively collects all workflow IDs within a folder and its subfolders.
*
* @param folderId - The folder ID to collect workflows from
* @param workflows - All workflows in the workspace
* @param folders - All folders in the workspace
* @returns Array of workflow IDs
* Recursively collects all workflows within a folder and its subfolders.
*/
function collectWorkflowsInFolder(
folderId: string,
workflows: Record<string, WorkflowMetadata>,
folders: Record<string, WorkflowFolder>
): string[] {
const workflowIds: string[] = []
): CollectedWorkflow[] {
const collectedWorkflows: CollectedWorkflow[] = []
for (const workflow of Object.values(workflows)) {
if (workflow.folderId === folderId) {
workflowIds.push(workflow.id)
collectedWorkflows.push({ id: workflow.id, folderId: workflow.folderId ?? null })
}
}
for (const folder of Object.values(folders)) {
if (folder.parentId === folderId) {
const childWorkflowIds = collectWorkflowsInFolder(folder.id, workflows, folders)
workflowIds.push(...childWorkflowIds)
const childWorkflows = collectWorkflowsInFolder(folder.id, workflows, folders)
collectedWorkflows.push(...childWorkflows)
}
}
return workflowIds
return collectedWorkflows
}
/**
* Collects all subfolders recursively under a root folder.
* Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null.
*/
function collectSubfolders(
rootFolderId: string,
folders: Record<string, WorkflowFolder>
): FolderExportData[] {
const subfolders: FolderExportData[] = []
function collect(parentId: string) {
for (const folder of Object.values(folders)) {
if (folder.parentId === parentId) {
subfolders.push({
id: folder.id,
name: folder.name,
// Direct children of root become top-level in export (parentId: null)
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
})
collect(folder.id)
}
}
}
collect(rootFolderId)
return subfolders
}
/**
* Hook for managing folder export to ZIP.
*
* @param props - Hook configuration
* @returns Export folder handlers and state
*/
export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) {
const { workflows } = useWorkflowRegistry()
const { folders } = useFolderStore()
const [isExporting, setIsExporting] = useState(false)
/**
* Check if the folder has any workflows (recursively)
*/
const hasWorkflows = useMemo(() => {
if (!folderId) return false
return collectWorkflowsInFolder(folderId, workflows, folders).length > 0
}, [folderId, workflows, folders])
/**
* Download file helper
*/
const downloadFile = (content: Blob, filename: string, mimeType = 'application/zip') => {
try {
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
logger.error('Failed to download file:', error)
}
}
/**
* Export all workflows in the folder (including nested subfolders) to ZIP
*/
const handleExportFolder = useCallback(async () => {
if (isExporting) {
return
}
if (!folderId) {
logger.warn('No folder ID provided for export')
if (isExporting || !folderId) {
return
}
@@ -117,98 +113,57 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
return
}
const workflowIdsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
if (workflowIdsToExport.length === 0) {
if (workflowsToExport.length === 0) {
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
return
}
const subfolders = collectSubfolders(folderId, folderStore.folders)
logger.info('Starting folder export', {
folderId,
folderName: folder.name,
workflowCount: workflowIdsToExport.length,
workflowCount: workflowsToExport.length,
subfolderCount: subfolders.length,
})
const exportedWorkflows: Array<{ name: string; content: string }> = []
const workflowExportData: WorkflowExportData[] = []
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
for (const collectedWorkflow of workflowsToExport) {
const workflowMeta = workflows[collectedWorkflow.id]
if (!workflowMeta) {
logger.warn(`Workflow ${collectedWorkflow.id} not found in registry`)
continue
}
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const remappedFolderId =
collectedWorkflow.folderId === folderId ? null : collectedWorkflow.folderId
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
const exportData = await fetchWorkflowForExport(collectedWorkflow.id, {
name: workflowMeta.name,
description: workflowMeta.description,
color: workflowMeta.color,
folderId: remappedFolderId,
})
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
if (exportData) {
workflowExportData.push(exportData)
logger.info(`Workflow ${collectedWorkflow.id} prepared for export`)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported from folder', {
if (workflowExportData.length === 0) {
logger.warn('No workflows were successfully prepared for export', {
folderId,
folderName: folder.name,
})
return
}
const zip = new JSZip()
const seenFilenames = new Set<string>()
for (const exportedWorkflow of exportedWorkflows) {
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
let filename = `${baseName}.json`
let counter = 1
while (seenFilenames.has(filename.toLowerCase())) {
filename = `${baseName}-${counter}.json`
counter++
}
seenFilenames.add(filename.toLowerCase())
zip.file(filename, exportedWorkflow.content)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip`
const zipBlob = await exportFolderToZip(folder.name, workflowExportData, subfolders)
const zipFilename = `${sanitizePathSegment(folder.name)}-export.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
const { clearSelection } = useFolderStore.getState()
@@ -217,7 +172,8 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
logger.info('Folder exported successfully', {
folderId,
folderName: folder.name,
workflowCount: exportedWorkflows.length,
workflowCount: workflowExportData.length,
subfolderCount: subfolders.length,
})
onSuccess?.()

View File

@@ -1,18 +1,18 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
import {
downloadFile,
exportWorkflowsToZip,
exportWorkflowToJson,
fetchWorkflowForExport,
sanitizePathSegment,
} from '@/lib/workflows/operations/import-export'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { Variable } from '@/stores/workflows/workflow/types'
const logger = createLogger('useExportWorkflow')
interface UseExportWorkflowProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* Optional callback after successful export
*/
@@ -20,44 +20,16 @@ interface UseExportWorkflowProps {
}
/**
* Hook for managing workflow export to JSON.
*
* @param props - Hook configuration
* @returns Export workflow handlers and state
* Hook for managing workflow export to JSON or ZIP.
*/
export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowProps) {
export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
const { workflows } = useWorkflowRegistry()
const [isExporting, setIsExporting] = useState(false)
/**
* Download file helper
*/
const downloadFile = (
content: Blob | string,
filename: string,
mimeType = 'application/json'
) => {
try {
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
logger.error('Failed to download file:', error)
}
}
/**
* Export the workflow(s) to JSON or ZIP
* - Single workflow: exports as JSON file
* - Multiple workflows: exports as ZIP file containing all JSON files
* Fetches workflow data from API to support bulk export of non-active workflows
* @param workflowIds - The workflow ID(s) to export
*/
const handleExportWorkflow = useCallback(
async (workflowIds: string | string[]) => {
@@ -78,85 +50,39 @@ export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowP
count: workflowIdsToExport.length,
})
const exportedWorkflows: Array<{ name: string; content: string }> = []
const exportedWorkflows = []
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
const workflowMeta = workflows[workflowId]
if (!workflowMeta) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const exportData = await fetchWorkflowForExport(workflowId, {
name: workflowMeta.name,
description: workflowMeta.description,
color: workflowMeta.color,
folderId: workflowMeta.folderId,
})
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
if (exportData) {
exportedWorkflows.push(exportData)
logger.info(`Workflow ${workflowId} prepared for export`)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported')
logger.warn('No workflows were successfully prepared for export')
return
}
if (exportedWorkflows.length === 1) {
const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json`
downloadFile(exportedWorkflows[0].content, filename, 'application/json')
const jsonContent = exportWorkflowToJson(exportedWorkflows[0])
const filename = `${sanitizePathSegment(exportedWorkflows[0].workflow.name)}.json`
downloadFile(jsonContent, filename, 'application/json')
} else {
const zip = new JSZip()
const seenFilenames = new Set<string>()
for (const exportedWorkflow of exportedWorkflows) {
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
let filename = `${baseName}.json`
let counter = 1
while (seenFilenames.has(filename.toLowerCase())) {
filename = `${baseName}-${counter}.json`
counter++
}
seenFilenames.add(filename.toLowerCase())
zip.file(filename, exportedWorkflow.content)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipBlob = await exportWorkflowsToZip(exportedWorkflows)
const zipFilename = `workflows-export-${Date.now()}.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
downloadFile,
exportWorkspaceToZip,
type FolderExportData,
fetchWorkflowForExport,
sanitizePathSegment,
type WorkflowExportData,
} from '@/lib/workflows/operations/import-export'
import type { Variable } from '@/stores/workflows/workflow/types'
const logger = createLogger('useExportWorkspace')
@@ -18,24 +20,10 @@ interface UseExportWorkspaceProps {
/**
* Hook for managing workspace export to ZIP.
*
* Handles:
* - Fetching all workflows and folders from workspace
* - Fetching workflow states and variables
* - Creating ZIP file with all workspace data
* - Downloading the ZIP file
* - Loading state management
* - Error handling and logging
*
* @param props - Hook configuration
* @returns Export workspace handlers and state
*/
export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) {
const [isExporting, setIsExporting] = useState(false)
/**
* Export workspace to ZIP file
*/
const handleExportWorkspace = useCallback(
async (workspaceId: string, workspaceName: string) => {
if (isExporting) return
@@ -59,39 +47,15 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
const workflowsToExport: WorkflowExportData[] = []
for (const workflow of workflows) {
try {
const workflowResponse = await fetch(`/api/workflows/${workflow.id}`)
if (!workflowResponse.ok) {
logger.warn(`Failed to fetch workflow ${workflow.id}`)
continue
}
const exportData = await fetchWorkflowForExport(workflow.id, {
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
})
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflow.id} has no state`)
continue
}
const variablesResponse = await fetch(`/api/workflows/${workflow.id}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
workflowsToExport.push({
workflow: {
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
},
state: workflowData.state,
variables: workflowVariables,
})
} catch (error) {
logger.error(`Failed to export workflow ${workflow.id}:`, error)
if (exportData) {
workflowsToExport.push(exportData)
}
}
@@ -109,14 +73,8 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
foldersToExport
)
const blobUrl = URL.createObjectURL(zipBlob)
const a = document.createElement('a')
a.href = blobUrl
a.download = `${workspaceName.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
const zipFilename = `${sanitizePathSegment(workspaceName)}-${Date.now()}.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
logger.info('Workspace exported successfully', {
workspaceId,

View File

@@ -0,0 +1,33 @@
import type { TaskState } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { deliverPushNotification } from '@/lib/a2a/push-notifications'
const logger = createLogger('A2APushNotificationDelivery')
export interface A2APushNotificationParams {
taskId: string
state: TaskState
}
export const a2aPushNotificationTask = task({
id: 'a2a-push-notification-delivery',
retry: {
maxAttempts: 5,
minTimeoutInMs: 1000,
maxTimeoutInMs: 60000,
factor: 2,
},
run: async (params: A2APushNotificationParams) => {
logger.info('Delivering A2A push notification', params)
const success = await deliverPushNotification(params.taskId, params.state)
if (!success) {
throw new Error(`Failed to deliver push notification for task ${params.taskId}`)
}
logger.info('A2A push notification delivered successfully', params)
return { success: true, taskId: params.taskId, state: params.state }
},
})

View File

@@ -10,6 +10,7 @@ import { getWorkflowById } from '@/lib/workflows/utils'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('TriggerWorkflowExecution')
@@ -17,7 +18,7 @@ export type WorkflowExecutionPayload = {
workflowId: string
userId: string
input?: any
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
triggerType?: CoreTriggerType
metadata?: Record<string, any>
}

View File

@@ -0,0 +1,306 @@
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 */
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<{ kind: string; text?: string; data?: unknown }>
}>
/** Full message history */
history?: Array<{
role: 'user' | 'agent'
parts: Array<{ kind: string; text?: string }>
}>
/** Whether cancellation was successful (cancel_task) */
cancelled?: boolean
/** Whether task is still running (resubscribe) */
isRunning?: 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[] }
/** Push notification webhook URL */
webhookUrl?: string
/** Push notification token */
token?: string
/** Whether push notification config exists */
exists?: boolean
/** Operation success indicator */
success?: boolean
}
}
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 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: 'a2a_send_message' },
{ label: 'Get Task', id: 'a2a_get_task' },
{ label: 'Cancel Task', id: 'a2a_cancel_task' },
{ label: 'Get Agent Card', id: 'a2a_get_agent_card' },
{ label: 'Resubscribe', id: 'a2a_resubscribe' },
{ label: 'Set Push Notification', id: 'a2a_set_push_notification' },
{ label: 'Get Push Notification', id: 'a2a_get_push_notification' },
{ label: 'Delete Push Notification', id: 'a2a_delete_push_notification' },
],
defaultValue: 'a2a_send_message',
},
{
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: 'a2a_send_message' },
required: true,
},
{
id: 'taskId',
title: 'Task ID',
type: 'short-input',
placeholder: 'Task ID',
description: 'Task ID to query, cancel, continue, or configure',
condition: {
field: 'operation',
value: [
'a2a_send_message',
'a2a_get_task',
'a2a_cancel_task',
'a2a_resubscribe',
'a2a_set_push_notification',
'a2a_get_push_notification',
'a2a_delete_push_notification',
],
},
required: {
field: 'operation',
value: [
'a2a_get_task',
'a2a_cancel_task',
'a2a_resubscribe',
'a2a_set_push_notification',
'a2a_get_push_notification',
'a2a_delete_push_notification',
],
},
},
{
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: 'a2a_send_message' },
},
{
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: 'a2a_get_task' },
},
{
id: 'webhookUrl',
title: 'Webhook URL',
type: 'short-input',
placeholder: 'https://your-app.com/webhook',
description: 'HTTPS webhook URL to receive task update notifications',
condition: { field: 'operation', value: 'a2a_set_push_notification' },
required: { field: 'operation', value: 'a2a_set_push_notification' },
},
{
id: 'token',
title: 'Webhook Token',
type: 'short-input',
password: true,
placeholder: 'Optional token for webhook validation',
description: 'Token that will be included in webhook requests for validation',
condition: { field: 'operation', value: 'a2a_set_push_notification' },
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
password: true,
placeholder: 'Optional API key for authenticated agents',
description:
'Optional API key sent via X-API-Key header for agents that require authentication',
},
],
tools: {
access: [
'a2a_send_message',
'a2a_get_task',
'a2a_cancel_task',
'a2a_get_agent_card',
'a2a_resubscribe',
'a2a_set_push_notification',
'a2a_get_push_notification',
'a2a_delete_push_notification',
],
config: {
tool: (params) => params.operation as string,
},
},
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, continue, or configure',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
historyLength: {
type: 'number',
description: 'Number of history messages to include',
},
webhookUrl: {
type: 'string',
description: 'HTTPS webhook URL for push notifications',
},
token: {
type: 'string',
description: 'Token for webhook validation',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
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',
},
cancelled: {
type: 'boolean',
description: 'Whether the task was successfully cancelled',
},
isRunning: {
type: 'boolean',
description: 'Whether the task is still running',
},
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',
},
webhookUrl: {
type: 'string',
description: 'Configured webhook URL',
},
token: {
type: 'string',
description: 'Webhook validation token',
},
exists: {
type: 'boolean',
description: 'Whether push notification config exists',
},
success: {
type: 'boolean',
description: 'Whether the operation was successful',
},
},
}

View File

@@ -1,3 +1,4 @@
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,6 +149,7 @@ 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

@@ -25,6 +25,7 @@ const badgeVariants = cva(
orange: `${STATUS_BASE} bg-[#fed7aa] text-[#c2410c] dark:bg-[rgba(249,115,22,0.2)] dark:text-[#fdba74]`,
amber: `${STATUS_BASE} bg-[#fde68a] text-[#a16207] dark:bg-[rgba(245,158,11,0.2)] dark:text-[#fcd34d]`,
teal: `${STATUS_BASE} bg-[#99f6e4] text-[#0f766e] dark:bg-[rgba(20,184,166,0.2)] dark:text-[#5eead4]`,
cyan: `${STATUS_BASE} bg-[#a5f3fc] text-[#0e7490] dark:bg-[rgba(14,165,233,0.2)] dark:text-[#7dd3fc]`,
'gray-secondary': `${STATUS_BASE} bg-[var(--surface-4)] text-[var(--text-secondary)]`,
},
size: {
@@ -51,6 +52,7 @@ const STATUS_VARIANTS = [
'orange',
'amber',
'teal',
'cyan',
'gray-secondary',
] as const
@@ -84,7 +86,7 @@ export interface BadgeProps
* Supports two categories of variants:
* - **Bordered**: `default`, `outline` - traditional badges with borders
* - **Status colors**: `green`, `red`, `gray`, `blue`, `blue-secondary`, `purple`,
* `orange`, `amber`, `teal`, `gray-secondary` - borderless colored badges
* `orange`, `amber`, `teal`, `cyan`, `gray-secondary` - borderless colored badges
*
* Status color variants can display a dot indicator via the `dot` prop.
* All variants support an optional `icon` prop for leading icons.

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