mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
71 Commits
SIM-514-us
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d615c78a30 | ||
|
|
b7f25786ce | ||
|
|
3a9e5f3b78 | ||
|
|
39444fa1a8 | ||
|
|
45ca926e6d | ||
|
|
77ee01747d | ||
|
|
474762d6fb | ||
|
|
0005c3e465 | ||
|
|
fc40b4f7af | ||
|
|
2a7f51a2f6 | ||
|
|
90c3c43607 | ||
|
|
83d813a7cc | ||
|
|
811c736705 | ||
|
|
c6757311af | ||
|
|
b5b12ba2d1 | ||
|
|
0d30676e34 | ||
|
|
36bdccb449 | ||
|
|
f45730a89e | ||
|
|
04cd837e9c | ||
|
|
c23130a26e | ||
|
|
7575cd6f27 | ||
|
|
fbde64f0b0 | ||
|
|
25f7ed20f6 | ||
|
|
261aa3d72d | ||
|
|
9da19e84b7 | ||
|
|
e83afc0a62 | ||
|
|
1720fa8749 | ||
|
|
f3ad7750af | ||
|
|
78b7643e65 | ||
|
|
7ef1150383 | ||
|
|
67cfb21d08 | ||
|
|
a337af92bc | ||
|
|
b4a99779eb | ||
|
|
471cb4747c | ||
|
|
491bd783b5 | ||
|
|
5516fa39c3 | ||
|
|
21fa92bc41 | ||
|
|
26ca37328a | ||
|
|
731997f768 | ||
|
|
1d6975db49 | ||
|
|
c4a6d11cc0 | ||
|
|
837aabca5e | ||
|
|
f9cfca92bf | ||
|
|
25afacb25e | ||
|
|
fcf52ac4d5 | ||
|
|
842200bcf2 | ||
|
|
a0fb889644 | ||
|
|
f526c36fc0 | ||
|
|
e24f31cbce | ||
|
|
3fbd57caf1 | ||
|
|
b5da61377c | ||
|
|
18b7032494 | ||
|
|
b7bbef8620 | ||
|
|
52edbea659 | ||
|
|
d480057fd3 | ||
|
|
c27c233da0 | ||
|
|
ebef5f3a27 | ||
|
|
12c4c2d44f | ||
|
|
929a352edb | ||
|
|
6cd078b0fe | ||
|
|
31874939ee | ||
|
|
e157ce5fbc | ||
|
|
774e5d585c | ||
|
|
54cc93743f | ||
|
|
8c32ad4c0d | ||
|
|
1d08796853 | ||
|
|
ebcd243942 | ||
|
|
b7e814b721 | ||
|
|
842ef27ed9 | ||
|
|
31c34b2ea3 | ||
|
|
8f0ef58056 |
13
.github/workflows/test-build.yml
vendored
13
.github/workflows/test-build.yml
vendored
@@ -48,6 +48,19 @@ jobs:
|
||||
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
|
||||
run: bun run test
|
||||
|
||||
- name: Check schema and migrations are in sync
|
||||
working-directory: packages/db
|
||||
run: |
|
||||
bunx drizzle-kit generate --config=./drizzle.config.ts
|
||||
if [ -n "$(git status --porcelain ./migrations)" ]; then
|
||||
echo "❌ Schema and migrations are out of sync!"
|
||||
echo "Run 'cd packages/db && bunx drizzle-kit generate' and commit the new migrations."
|
||||
git status --porcelain ./migrations
|
||||
git diff ./migrations
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Schema and migrations are in sync"
|
||||
|
||||
- name: Build application
|
||||
env:
|
||||
NODE_OPTIONS: '--no-warnings'
|
||||
|
||||
@@ -188,6 +188,7 @@ DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
|
||||
Then run the migrations:
|
||||
```bash
|
||||
cd packages/db # Required so drizzle picks correct .env file
|
||||
bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
```
|
||||
|
||||
|
||||
23
apps/docs/app/[lang]/not-found.tsx
Normal file
23
apps/docs/app/[lang]/not-found.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DocsBody, DocsPage } from 'fumadocs-ui/page'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Page Not Found',
|
||||
}
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<DocsPage>
|
||||
<DocsBody>
|
||||
<div className='flex min-h-[60vh] flex-col items-center justify-center text-center'>
|
||||
<h1 className='mb-4 bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] bg-clip-text font-bold text-8xl text-transparent'>
|
||||
404
|
||||
</h1>
|
||||
<h2 className='mb-2 font-semibold text-2xl text-foreground'>Page Not Found</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
)
|
||||
}
|
||||
@@ -2452,6 +2452,56 @@ export const GeminiIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VertexIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
id='standard_product_icon'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
version='1.1'
|
||||
viewBox='0 0 512 512'
|
||||
>
|
||||
<g id='bounding_box'>
|
||||
<rect width='512' height='512' fill='none' />
|
||||
</g>
|
||||
<g id='art'>
|
||||
<path
|
||||
d='M128,244.99c-8.84,0-16-7.16-16-16v-95.97c0-8.84,7.16-16,16-16s16,7.16,16,16v95.97c0,8.84-7.16,16-16,16Z'
|
||||
fill='#ea4335'
|
||||
/>
|
||||
<path
|
||||
d='M256,458c-2.98,0-5.97-.83-8.59-2.5l-186-122c-7.46-4.74-9.65-14.63-4.91-22.09,4.75-7.46,14.64-9.65,22.09-4.91l177.41,116.53,177.41-116.53c7.45-4.74,17.34-2.55,22.09,4.91,4.74,7.46,2.55,17.34-4.91,22.09l-186,122c-2.62,1.67-5.61,2.5-8.59,2.5Z'
|
||||
fill='#fbbc04'
|
||||
/>
|
||||
<path
|
||||
d='M256,388.03c-8.84,0-16-7.16-16-16v-73.06c0-8.84,7.16-16,16-16s16,7.16,16,16v73.06c0,8.84-7.16,16-16,16Z'
|
||||
fill='#34a853'
|
||||
/>
|
||||
<circle cx='128' cy='70' r='16' fill='#ea4335' />
|
||||
<circle cx='128' cy='292' r='16' fill='#ea4335' />
|
||||
<path
|
||||
d='M384.23,308.01c-8.82,0-15.98-7.14-16-15.97l-.23-94.01c-.02-8.84,7.13-16.02,15.97-16.03h.04c8.82,0,15.98,7.14,16,15.97l.23,94.01c.02,8.84-7.13,16.02-15.97,16.03h-.04Z'
|
||||
fill='#4285f4'
|
||||
/>
|
||||
<circle cx='384' cy='70' r='16' fill='#4285f4' />
|
||||
<circle cx='384' cy='134' r='16' fill='#4285f4' />
|
||||
<path
|
||||
d='M320,220.36c-8.84,0-16-7.16-16-16v-103.02c0-8.84,7.16-16,16-16s16,7.16,16,16v103.02c0,8.84-7.16,16-16,16Z'
|
||||
fill='#fbbc04'
|
||||
/>
|
||||
<circle cx='256' cy='171' r='16' fill='#34a853' />
|
||||
<circle cx='256' cy='235' r='16' fill='#34a853' />
|
||||
<circle cx='320' cy='265' r='16' fill='#fbbc04' />
|
||||
<circle cx='320' cy='329' r='16' fill='#fbbc04' />
|
||||
<path
|
||||
d='M192,217.36c-8.84,0-16-7.16-16-16v-100.02c0-8.84,7.16-16,16-16s16,7.16,16,16v100.02c0,8.84-7.16,16-16,16Z'
|
||||
fill='#fbbc04'
|
||||
/>
|
||||
<circle cx='192' cy='265' r='16' fill='#fbbc04' />
|
||||
<circle cx='192' cy='329' r='16' fill='#fbbc04' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CerebrasIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
@@ -3337,17 +3387,14 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function ServiceNowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 1570 1403'
|
||||
width='48'
|
||||
height='48'
|
||||
>
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 71.1 63.6'>
|
||||
<path
|
||||
fill='#62d84e'
|
||||
fillRule='evenodd'
|
||||
d='M1228.4 138.9c129.2 88.9 228.9 214.3 286.3 360.2 57.5 145.8 70 305.5 36 458.5S1437.8 1250 1324 1357.9c-13.3 12.9-28.8 23.4-45.8 30.8-17 7.5-35.2 11.9-53.7 12.9-18.5 1.1-37.1-1.1-54.8-6.6-17.7-5.4-34.3-13.9-49.1-25.2-48.2-35.9-101.8-63.8-158.8-82.6-57.1-18.9-116.7-28.5-176.8-28.5s-119.8 9.6-176.8 28.5c-57 18.8-110.7 46.7-158.9 82.6-14.6 11.2-31 19.8-48.6 25.3s-36 7.8-54.4 6.8c-18.4-.9-36.5-5.1-53.4-12.4s-32.4-17.5-45.8-30.2C132.5 1251 53 1110.8 19 956.8s-20.9-314.6 37.6-461c58.5-146.5 159.6-272 290.3-360.3S631.8.1 789.6.5c156.8 1.3 309.6 49.6 438.8 138.4m-291.8 1014c48.2-19.2 92-48 128.7-84.6 36.7-36.7 65.5-80.4 84.7-128.6 19.2-48.1 28.4-99.7 27-151.5 0-103.9-41.3-203.5-114.8-277S889 396.4 785 396.4s-203.7 41.3-277.2 114.8S393 684.3 393 788.2c-1.4 51.8 7.8 103.4 27 151.5 19.2 48.2 48 91.9 84.7 128.6 36.7 36.6 80.5 65.4 128.6 84.6 48.2 19.2 99.8 28.4 151.7 27 51.8 1.4 103.4-7.8 151.6-27'
|
||||
clipRule='evenodd'
|
||||
fill='#62D84E'
|
||||
d='M35.8,0C16.1,0,0,15.9,0,35.6c0,9.8,4,19.3,11.2,26c2.5,2.4,6.4,2.6,9.2,0.5c9-6.7,21.4-6.7,30.4,0
|
||||
c2.8,2.1,6.7,1.9,9.2-0.5C74.3,48,74.9,25.4,61.3,11.1C54.7,4.1,45.4,0.1,35.8,0 M35.6,53.5C26,53.8,18,46.2,17.8,36.7
|
||||
c0-0.3,0-0.6,0-0.9c0-9.8,8-17.8,17.8-17.8s17.8,8,17.8,17.8c0.3,9.6-7.3,17.5-16.8,17.8C36.2,53.5,35.9,53.5,35.6,53.5'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -120,117 +120,117 @@ import {
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
calendly: CalendlyIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
postgresql: PostgresIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
rds: RDSIcon,
|
||||
translate: TranslateIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
wordpress: WordpressIcon,
|
||||
tavily: TavilyIcon,
|
||||
zoom: ZoomIcon,
|
||||
zep: ZepIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
youtube: YouTubeIcon,
|
||||
supabase: SupabaseIcon,
|
||||
vision: EyeIcon,
|
||||
zoom: ZoomIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
arxiv: ArxivIcon,
|
||||
webflow: WebflowIcon,
|
||||
pinecone: PineconeIcon,
|
||||
apollo: ApolloIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
typeform: TypeformIcon,
|
||||
qdrant: QdrantIcon,
|
||||
shopify: ShopifyIcon,
|
||||
asana: AsanaIcon,
|
||||
sqs: SQSIcon,
|
||||
apify: ApifyIcon,
|
||||
memory: BrainIcon,
|
||||
gitlab: GitLabIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
serper: SerperIcon,
|
||||
linear: LinearIcon,
|
||||
exa: ExaAIIcon,
|
||||
telegram: TelegramIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
hunter: HunterIOIcon,
|
||||
linkup: LinkupIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
airtable: AirtableIcon,
|
||||
discord: DiscordIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
tts: TTSIcon,
|
||||
jina: JinaAIIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
google_search: GoogleIcon,
|
||||
x: xIcon,
|
||||
kalshi: KalshiIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
zep: ZepIcon,
|
||||
posthog: PosthogIcon,
|
||||
grafana: GrafanaIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
thinking: BrainIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
dropbox: DropboxIcon,
|
||||
stagehand: StagehandIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
file: DocumentIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
gmail: GmailIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
resend: ResendIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
video_generator: VideoIcon,
|
||||
smtp: SmtpIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
mailgun: MailgunIcon,
|
||||
clay: ClayIcon,
|
||||
jira: JiraIcon,
|
||||
search: SearchIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
notion: NotionIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
github: GithubIcon,
|
||||
sftp: SftpIcon,
|
||||
ssh: SshIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
sentry: SentryIcon,
|
||||
reddit: RedditIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
spotify: SpotifyIcon,
|
||||
stripe: StripeIcon,
|
||||
s3: S3Icon,
|
||||
trello: TrelloIcon,
|
||||
mem0: Mem0Icon,
|
||||
knowledge: PackageSearchIcon,
|
||||
intercom: IntercomIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
slack: SlackIcon,
|
||||
datadog: DatadogIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
image_generator: ImageIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
wordpress: WordpressIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
cursor: CursorIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
mysql: MySQLIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
webflow: WebflowIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
vision: EyeIcon,
|
||||
video_generator: VideoIcon,
|
||||
typeform: TypeformIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
tts: TTSIcon,
|
||||
trello: TrelloIcon,
|
||||
translate: TranslateIcon,
|
||||
thinking: BrainIcon,
|
||||
telegram: TelegramIcon,
|
||||
tavily: TavilyIcon,
|
||||
supabase: SupabaseIcon,
|
||||
stt: STTIcon,
|
||||
stripe: StripeIcon,
|
||||
stagehand: StagehandIcon,
|
||||
ssh: SshIcon,
|
||||
sqs: SQSIcon,
|
||||
spotify: SpotifyIcon,
|
||||
smtp: SmtpIcon,
|
||||
slack: SlackIcon,
|
||||
shopify: ShopifyIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
sftp: SftpIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
serper: SerperIcon,
|
||||
sentry: SentryIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
search: SearchIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
s3: S3Icon,
|
||||
resend: ResendIcon,
|
||||
reddit: RedditIcon,
|
||||
rds: RDSIcon,
|
||||
qdrant: QdrantIcon,
|
||||
posthog: PosthogIcon,
|
||||
postgresql: PostgresIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
pinecone: PineconeIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
outlook: OutlookIcon,
|
||||
openai: OpenAIIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
notion: NotionIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
mysql: MySQLIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
memory: BrainIcon,
|
||||
mem0: Mem0Icon,
|
||||
mailgun: MailgunIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
linkup: LinkupIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linear: LinearIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
kalshi: KalshiIcon,
|
||||
jira: JiraIcon,
|
||||
jina: JinaAIIcon,
|
||||
intercom: IntercomIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
image_generator: ImageIcon,
|
||||
hunter: HunterIOIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
grafana: GrafanaIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_search: GoogleIcon,
|
||||
gmail: GmailIcon,
|
||||
gitlab: GitLabIcon,
|
||||
github: GithubIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
file: DocumentIcon,
|
||||
exa: ExaAIIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dropbox: DropboxIcon,
|
||||
discord: DiscordIcon,
|
||||
datadog: DatadogIcon,
|
||||
cursor: CursorIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
clay: ClayIcon,
|
||||
calendly: CalendlyIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
asana: AsanaIcon,
|
||||
arxiv: ArxivIcon,
|
||||
apollo: ApolloIcon,
|
||||
apify: ApifyIcon,
|
||||
airtable: AirtableIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
}
|
||||
|
||||
@@ -111,26 +111,24 @@ Verschiedene Blocktypen erzeugen unterschiedliche Ausgabestrukturen. Hier ist, w
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Original content passed through",
|
||||
"conditionResult": true,
|
||||
"selectedPath": {
|
||||
"blockId": "2acd9007-27e8-4510-a487-73d3b825e7c1",
|
||||
"blockType": "agent",
|
||||
"blockTitle": "Follow-up Agent"
|
||||
},
|
||||
"selectedConditionId": "condition-1"
|
||||
"selectedOption": "condition-1"
|
||||
}
|
||||
```
|
||||
|
||||
### Ausgabefelder des Condition-Blocks
|
||||
|
||||
- **content**: Der ursprüngliche, durchgeleitete Inhalt
|
||||
- **conditionResult**: Boolesches Ergebnis der Bedingungsauswertung
|
||||
- **selectedPath**: Informationen über den ausgewählten Pfad
|
||||
- **blockId**: ID des nächsten Blocks im ausgewählten Pfad
|
||||
- **blockType**: Typ des nächsten Blocks
|
||||
- **blockTitle**: Titel des nächsten Blocks
|
||||
- **selectedConditionId**: ID der ausgewählten Bedingung
|
||||
- **selectedOption**: ID der ausgewählten Bedingung
|
||||
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -90,14 +90,20 @@ Ein Jira-Issue erstellen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Ja | Ihre Jira-Domain (z.B. ihrfirma.atlassian.net) |
|
||||
| `domain` | string | Ja | Ihre Jira-Domain \(z.B. ihrfirma.atlassian.net\) |
|
||||
| `projectId` | string | Ja | Projekt-ID für das Issue |
|
||||
| `summary` | string | Ja | Zusammenfassung für das Issue |
|
||||
| `description` | string | Nein | Beschreibung für das Issue |
|
||||
| `priority` | string | Nein | Priorität für das Issue |
|
||||
| `assignee` | string | Nein | Bearbeiter für das Issue |
|
||||
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie anhand der Domain abgerufen. |
|
||||
| `issueType` | string | Ja | Art des zu erstellenden Issues (z.B. Task, Story) |
|
||||
| `priority` | string | Nein | Prioritäts-ID oder -Name für das Issue \(z.B. "10000" oder "High"\) |
|
||||
| `assignee` | string | Nein | Account-ID des Bearbeiters für das Issue |
|
||||
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie über die Domain abgerufen. |
|
||||
| `issueType` | string | Ja | Typ des zu erstellenden Issues \(z.B. Task, Story\) |
|
||||
| `labels` | array | Nein | Labels für das Issue \(Array von Label-Namen\) |
|
||||
| `duedate` | string | Nein | Fälligkeitsdatum für das Issue \(Format: YYYY-MM-DD\) |
|
||||
| `reporter` | string | Nein | Account-ID des Melders für das Issue |
|
||||
| `environment` | string | Nein | Umgebungsinformationen für das Issue |
|
||||
| `customFieldId` | string | Nein | Benutzerdefinierte Feld-ID \(z.B. customfield_10001\) |
|
||||
| `customFieldValue` | string | Nein | Wert für das benutzerdefinierte Feld |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
@@ -107,6 +113,7 @@ Ein Jira-Issue erstellen
|
||||
| `issueKey` | string | Erstellter Issue-Key \(z.B. PROJ-123\) |
|
||||
| `summary` | string | Issue-Zusammenfassung |
|
||||
| `url` | string | URL zum erstellten Issue |
|
||||
| `assigneeId` | string | Account-ID des zugewiesenen Benutzers \(falls zugewiesen\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,6 +527,30 @@ Einen Beobachter von einem Jira-Issue entfernen
|
||||
| `issueKey` | string | Issue-Key |
|
||||
| `watcherAccountId` | string | Account-ID des entfernten Beobachters |
|
||||
|
||||
### `jira_get_users`
|
||||
|
||||
Jira-Benutzer abrufen. Wenn eine Account-ID angegeben wird, wird ein einzelner Benutzer zurückgegeben. Andernfalls wird eine Liste aller Benutzer zurückgegeben.
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Ja | Ihre Jira-Domain \(z.B. ihrfirma.atlassian.net\) |
|
||||
| `accountId` | string | Nein | Optionale Account-ID, um einen bestimmten Benutzer abzurufen. Wenn nicht angegeben, werden alle Benutzer zurückgegeben. |
|
||||
| `startAt` | number | Nein | Der Index des ersten zurückzugebenden Benutzers \(für Paginierung, Standard: 0\) |
|
||||
| `maxResults` | number | Nein | Maximale Anzahl der zurückzugebenden Benutzer \(Standard: 50\) |
|
||||
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie anhand der Domain abgerufen. |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Zeitstempel der Operation |
|
||||
| `users` | json | Array von Benutzern mit accountId, displayName, emailAddress, active-Status und avatarUrls |
|
||||
| `total` | number | Gesamtanzahl der zurückgegebenen Benutzer |
|
||||
| `startAt` | number | Startindex für Paginierung |
|
||||
| `maxResults` | number | Maximale Ergebnisse pro Seite |
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Kategorie: `tools`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Erstellen, lesen, aktualisieren, löschen und Massenimport von
|
||||
ServiceNow-Datensätzen
|
||||
description: ServiceNow-Datensätze erstellen, lesen, aktualisieren und löschen
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -11,22 +10,36 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
## Nutzungsanleitung
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[ServiceNow](https://www.servicenow.com/) ist eine leistungsstarke Cloud-Plattform zur Optimierung und Automatisierung von IT-Service-Management (ITSM), Workflows und Geschäftsprozessen in Ihrem Unternehmen. ServiceNow ermöglicht Ihnen die Verwaltung von Vorfällen, Anfragen, Aufgaben, Benutzern und mehr über seine umfangreiche API.
|
||||
|
||||
Integrieren Sie ServiceNow in Ihren Workflow. Kann Datensätze in jeder ServiceNow-Tabelle erstellen, lesen, aktualisieren und löschen (Vorfälle, Aufgaben, Benutzer usw.). Unterstützt Massenimport-Operationen für Datenmigration und ETL.
|
||||
Mit ServiceNow können Sie:
|
||||
|
||||
- **IT-Workflows automatisieren**: Datensätze in jeder ServiceNow-Tabelle erstellen, lesen, aktualisieren und löschen, z. B. Vorfälle, Aufgaben, Änderungsanfragen und Benutzer.
|
||||
- **Systeme integrieren**: ServiceNow mit Ihren anderen Tools und Prozessen für nahtlose Automatisierung verbinden.
|
||||
- **Eine einzige Informationsquelle pflegen**: Alle Ihre Service- und Betriebsdaten organisiert und zugänglich halten.
|
||||
- **Betriebliche Effizienz steigern**: Manuelle Arbeit reduzieren und Servicequalität mit anpassbaren Workflows und Automatisierung verbessern.
|
||||
|
||||
In Sim ermöglicht die ServiceNow-Integration Ihren Agenten, direkt mit Ihrer ServiceNow-Instanz als Teil ihrer Workflows zu interagieren. Agenten können Datensätze in jeder ServiceNow-Tabelle erstellen, lesen, aktualisieren oder löschen und Ticket- oder Benutzerdaten für ausgefeilte Automatisierung und Entscheidungsfindung nutzen. Diese Integration verbindet Ihre Workflow-Automatisierung und IT-Betrieb und befähigt Ihre Agenten, Serviceanfragen, Vorfälle, Benutzer und Assets ohne manuelle Eingriffe zu verwalten. Durch die Verbindung von Sim mit ServiceNow können Sie Service-Management-Aufgaben automatisieren, Reaktionszeiten verbessern und konsistenten, sicheren Zugriff auf die wichtigen Servicedaten Ihres Unternehmens gewährleisten.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Nutzungsanweisungen
|
||||
|
||||
Integrieren Sie ServiceNow in Ihren Workflow. Erstellen, lesen, aktualisieren und löschen Sie Datensätze in jeder ServiceNow-Tabelle, einschließlich Vorfälle, Aufgaben, Änderungsanfragen, Benutzer und mehr.
|
||||
|
||||
## Tools
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
Erstellen eines neuen Datensatzes in einer ServiceNow-Tabelle
|
||||
Einen neuen Datensatz in einer ServiceNow-Tabelle erstellen
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Ja | ServiceNow-Instanz-URL \(z. B. https://instance.service-now.com\) |
|
||||
| `credential` | string | Nein | ServiceNow OAuth-Anmeldeinformations-ID |
|
||||
| `username` | string | Ja | ServiceNow-Benutzername |
|
||||
| `password` | string | Ja | ServiceNow-Passwort |
|
||||
| `tableName` | string | Ja | Tabellenname \(z. B. incident, task, sys_user\) |
|
||||
| `fields` | json | Ja | Felder, die für den Datensatz festgelegt werden sollen \(JSON-Objekt\) |
|
||||
|
||||
@@ -39,14 +52,15 @@ Erstellen eines neuen Datensatzes in einer ServiceNow-Tabelle
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
Lesen von Datensätzen aus einer ServiceNow-Tabelle
|
||||
Datensätze aus einer ServiceNow-Tabelle lesen
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Nein | ServiceNow-Instanz-URL \(automatisch aus OAuth erkannt, falls nicht angegeben\) |
|
||||
| `credential` | string | Nein | ServiceNow OAuth-Anmeldeinformations-ID |
|
||||
| `instanceUrl` | string | Ja | ServiceNow-Instanz-URL \(z. B. https://instance.service-now.com\) |
|
||||
| `username` | string | Ja | ServiceNow-Benutzername |
|
||||
| `password` | string | Ja | ServiceNow-Passwort |
|
||||
| `tableName` | string | Ja | Tabellenname |
|
||||
| `sysId` | string | Nein | Spezifische Datensatz-sys_id |
|
||||
| `number` | string | Nein | Datensatznummer \(z. B. INC0010001\) |
|
||||
@@ -69,10 +83,11 @@ Einen bestehenden Datensatz in einer ServiceNow-Tabelle aktualisieren
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Nein | ServiceNow-Instanz-URL \(wird automatisch aus OAuth erkannt, falls nicht angegeben\) |
|
||||
| `credential` | string | Nein | ServiceNow-OAuth-Credential-ID |
|
||||
| `instanceUrl` | string | Ja | ServiceNow-Instanz-URL \(z. B. https://instance.service-now.com\) |
|
||||
| `username` | string | Ja | ServiceNow-Benutzername |
|
||||
| `password` | string | Ja | ServiceNow-Passwort |
|
||||
| `tableName` | string | Ja | Tabellenname |
|
||||
| `sysId` | string | Ja | Sys_id des zu aktualisierenden Datensatzes |
|
||||
| `sysId` | string | Ja | Datensatz-sys_id zum Aktualisieren |
|
||||
| `fields` | json | Ja | Zu aktualisierende Felder \(JSON-Objekt\) |
|
||||
|
||||
#### Ausgabe
|
||||
@@ -90,10 +105,11 @@ Einen Datensatz aus einer ServiceNow-Tabelle löschen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Nein | ServiceNow-Instanz-URL \(wird automatisch aus OAuth erkannt, falls nicht angegeben\) |
|
||||
| `credential` | string | Nein | ServiceNow-OAuth-Credential-ID |
|
||||
| `instanceUrl` | string | Ja | ServiceNow-Instanz-URL \(z. B. https://instance.service-now.com\) |
|
||||
| `username` | string | Ja | ServiceNow-Benutzername |
|
||||
| `password` | string | Ja | ServiceNow-Passwort |
|
||||
| `tableName` | string | Ja | Tabellenname |
|
||||
| `sysId` | string | Ja | Sys_id des zu löschenden Datensatzes |
|
||||
| `sysId` | string | Ja | Datensatz-sys_id zum Löschen |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
|
||||
@@ -39,14 +39,16 @@ Senden Sie eine Chat-Completion-Anfrage an jeden unterstützten LLM-Anbieter
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `model` | string | Ja | Das zu verwendende Modell (z.B. gpt-4o, claude-sonnet-4-5, gemini-2.0-flash) |
|
||||
| `systemPrompt` | string | Nein | System-Prompt zur Festlegung des Assistentenverhaltens |
|
||||
| `context` | string | Ja | Die Benutzernachricht oder der Kontext, der an das Modell gesendet wird |
|
||||
| `apiKey` | string | Nein | API-Schlüssel für den Anbieter (verwendet den Plattformschlüssel, wenn für gehostete Modelle nicht angegeben) |
|
||||
| `temperature` | number | Nein | Temperatur für die Antwortgenerierung (0-2) |
|
||||
| `maxTokens` | number | Nein | Maximale Tokens in der Antwort |
|
||||
| `model` | string | Ja | Das zu verwendende Modell \(z. B. gpt-4o, claude-sonnet-4-5, gemini-2.0-flash\) |
|
||||
| `systemPrompt` | string | Nein | System-Prompt zur Festlegung des Verhaltens des Assistenten |
|
||||
| `context` | string | Ja | Die Benutzernachricht oder der Kontext, der an das Modell gesendet werden soll |
|
||||
| `apiKey` | string | Nein | API-Schlüssel für den Anbieter \(verwendet Plattform-Schlüssel, falls nicht für gehostete Modelle angegeben\) |
|
||||
| `temperature` | number | Nein | Temperatur für die Antwortgenerierung \(0-2\) |
|
||||
| `maxTokens` | number | Nein | Maximale Anzahl von Tokens in der Antwort |
|
||||
| `azureEndpoint` | string | Nein | Azure OpenAI-Endpunkt-URL |
|
||||
| `azureApiVersion` | string | Nein | Azure OpenAI API-Version |
|
||||
| `azureApiVersion` | string | Nein | Azure OpenAI-API-Version |
|
||||
| `vertexProject` | string | Nein | Google Cloud-Projekt-ID für Vertex AI |
|
||||
| `vertexLocation` | string | Nein | Google Cloud-Standort für Vertex AI \(Standard: us-central1\) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
|
||||
@@ -106,26 +106,24 @@ Different block types produce different output structures. Here's what you can e
|
||||
<Tab>
|
||||
```json
|
||||
{
|
||||
"content": "Original content passed through",
|
||||
"conditionResult": true,
|
||||
"selectedPath": {
|
||||
"blockId": "2acd9007-27e8-4510-a487-73d3b825e7c1",
|
||||
"blockType": "agent",
|
||||
"blockTitle": "Follow-up Agent"
|
||||
},
|
||||
"selectedConditionId": "condition-1"
|
||||
"selectedOption": "condition-1"
|
||||
}
|
||||
```
|
||||
|
||||
### Condition Block Output Fields
|
||||
|
||||
- **content**: The original content passed through
|
||||
- **conditionResult**: Boolean result of the condition evaluation
|
||||
- **selectedPath**: Information about the selected path
|
||||
- **blockId**: ID of the next block in the selected path
|
||||
- **blockType**: Type of the next block
|
||||
- **blockTitle**: Title of the next block
|
||||
- **selectedConditionId**: ID of the selected condition
|
||||
- **selectedOption**: ID of the selected condition
|
||||
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -97,10 +97,16 @@ Write a Jira issue
|
||||
| `projectId` | string | Yes | Project ID for the issue |
|
||||
| `summary` | string | Yes | Summary for the issue |
|
||||
| `description` | string | No | Description for the issue |
|
||||
| `priority` | string | No | Priority for the issue |
|
||||
| `assignee` | string | No | Assignee for the issue |
|
||||
| `priority` | string | No | Priority ID or name for the issue \(e.g., "10000" or "High"\) |
|
||||
| `assignee` | string | No | Assignee account ID for the issue |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
| `issueType` | string | Yes | Type of issue to create \(e.g., Task, Story\) |
|
||||
| `labels` | array | No | Labels for the issue \(array of label names\) |
|
||||
| `duedate` | string | No | Due date for the issue \(format: YYYY-MM-DD\) |
|
||||
| `reporter` | string | No | Reporter account ID for the issue |
|
||||
| `environment` | string | No | Environment information for the issue |
|
||||
| `customFieldId` | string | No | Custom field ID \(e.g., customfield_10001\) |
|
||||
| `customFieldValue` | string | No | Value for the custom field |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -110,6 +116,7 @@ Write a Jira issue
|
||||
| `issueKey` | string | Created issue key \(e.g., PROJ-123\) |
|
||||
| `summary` | string | Issue summary |
|
||||
| `url` | string | URL to the created issue |
|
||||
| `assigneeId` | string | Account ID of the assigned user \(if assigned\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -523,6 +530,30 @@ Remove a watcher from a Jira issue
|
||||
| `issueKey` | string | Issue key |
|
||||
| `watcherAccountId` | string | Removed watcher account ID |
|
||||
|
||||
### `jira_get_users`
|
||||
|
||||
Get Jira users. If an account ID is provided, returns a single user. Otherwise, returns a list of all users.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `accountId` | string | No | Optional account ID to get a specific user. If not provided, returns all users. |
|
||||
| `startAt` | number | No | The index of the first user to return \(for pagination, default: 0\) |
|
||||
| `maxResults` | number | No | Maximum number of users to return \(default: 50\) |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Timestamp of the operation |
|
||||
| `users` | json | Array of users with accountId, displayName, emailAddress, active status, and avatarUrls |
|
||||
| `total` | number | Total number of users returned |
|
||||
| `startAt` | number | Pagination start index |
|
||||
| `maxResults` | number | Maximum results per page |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Create, read, update, delete, and bulk import ServiceNow records
|
||||
description: Create, read, update, and delete ServiceNow records
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -10,9 +10,23 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[ServiceNow](https://www.servicenow.com/) is a powerful cloud platform designed to streamline and automate IT service management (ITSM), workflows, and business processes across your organization. ServiceNow enables you to manage incidents, requests, tasks, users, and more using its extensive API.
|
||||
|
||||
With ServiceNow, you can:
|
||||
|
||||
- **Automate IT workflows**: Create, read, update, and delete records in any ServiceNow table, such as incidents, tasks, change requests, and users.
|
||||
- **Integrate systems**: Connect ServiceNow with your other tools and processes for seamless automation.
|
||||
- **Maintain a single source of truth**: Keep all your service and operations data organized and accessible.
|
||||
- **Drive operational efficiency**: Reduce manual work and improve service quality with customizable workflows and automation.
|
||||
|
||||
In Sim, the ServiceNow integration enables your agents to interact directly with your ServiceNow instance as part of their workflows. Agents can create, read, update, or delete records in any ServiceNow table and leverage ticket or user data for sophisticated automation and decision-making. This integration bridges your workflow automation and IT operations, empowering your agents to manage service requests, incidents, users, and assets without manual intervention. By connecting Sim with ServiceNow, you can automate service management tasks, improve response times, and ensure consistent, secure access to your organization's vital service data.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL.
|
||||
Integrate ServiceNow into your workflow. Create, read, update, and delete records in any ServiceNow table including incidents, tasks, change requests, users, and more.
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +41,8 @@ Create a new record in a ServiceNow table
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `username` | string | Yes | ServiceNow username |
|
||||
| `password` | string | Yes | ServiceNow password |
|
||||
| `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) |
|
||||
| `fields` | json | Yes | Fields to set on the record \(JSON object\) |
|
||||
|
||||
@@ -46,8 +61,9 @@ Read records from a ServiceNow table
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
|
||||
| `username` | string | Yes | ServiceNow username |
|
||||
| `password` | string | Yes | ServiceNow password |
|
||||
| `tableName` | string | Yes | Table name |
|
||||
| `sysId` | string | No | Specific record sys_id |
|
||||
| `number` | string | No | Record number \(e.g., INC0010001\) |
|
||||
@@ -70,8 +86,9 @@ Update an existing record in a ServiceNow table
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
|
||||
| `username` | string | Yes | ServiceNow username |
|
||||
| `password` | string | Yes | ServiceNow password |
|
||||
| `tableName` | string | Yes | Table name |
|
||||
| `sysId` | string | Yes | Record sys_id to update |
|
||||
| `fields` | json | Yes | Fields to update \(JSON object\) |
|
||||
@@ -91,8 +108,9 @@ Delete a record from a ServiceNow table
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
|
||||
| `username` | string | Yes | ServiceNow username |
|
||||
| `password` | string | Yes | ServiceNow password |
|
||||
| `tableName` | string | Yes | Table name |
|
||||
| `sysId` | string | Yes | Record sys_id to delete |
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ Send a chat completion request to any supported LLM provider
|
||||
| `maxTokens` | number | No | Maximum tokens in the response |
|
||||
| `azureEndpoint` | string | No | Azure OpenAI endpoint URL |
|
||||
| `azureApiVersion` | string | No | Azure OpenAI API version |
|
||||
| `vertexProject` | string | No | Google Cloud project ID for Vertex AI |
|
||||
| `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -111,26 +111,24 @@ Diferentes tipos de bloques producen diferentes estructuras de salida. Esto es l
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Original content passed through",
|
||||
"conditionResult": true,
|
||||
"selectedPath": {
|
||||
"blockId": "2acd9007-27e8-4510-a487-73d3b825e7c1",
|
||||
"blockType": "agent",
|
||||
"blockTitle": "Follow-up Agent"
|
||||
},
|
||||
"selectedConditionId": "condition-1"
|
||||
"selectedOption": "condition-1"
|
||||
}
|
||||
```
|
||||
|
||||
### Campos de salida del bloque de condición
|
||||
|
||||
- **content**: El contenido original que se transmite
|
||||
- **conditionResult**: Resultado booleano de la evaluación de la condición
|
||||
- **selectedPath**: Información sobre la ruta seleccionada
|
||||
- **conditionResult**: resultado booleano de la evaluación de la condición
|
||||
- **selectedPath**: información sobre la ruta seleccionada
|
||||
- **blockId**: ID del siguiente bloque en la ruta seleccionada
|
||||
- **blockType**: Tipo del siguiente bloque
|
||||
- **blockTitle**: Título del siguiente bloque
|
||||
- **selectedConditionId**: ID de la condición seleccionada
|
||||
- **blockType**: tipo del siguiente bloque
|
||||
- **blockTitle**: título del siguiente bloque
|
||||
- **selectedOption**: ID de la condición seleccionada
|
||||
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -89,24 +89,31 @@ Escribir una incidencia de Jira
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | string | Sí | Tu dominio de Jira \(p. ej., tuempresa.atlassian.net\) |
|
||||
| `projectId` | string | Sí | ID del proyecto para la incidencia |
|
||||
| `summary` | string | Sí | Resumen de la incidencia |
|
||||
| `description` | string | No | Descripción de la incidencia |
|
||||
| `priority` | string | No | Prioridad de la incidencia |
|
||||
| `assignee` | string | No | Asignado para la incidencia |
|
||||
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá utilizando el dominio. |
|
||||
| `priority` | string | No | ID o nombre de prioridad para la incidencia \(p. ej., "10000" o "Alta"\) |
|
||||
| `assignee` | string | No | ID de cuenta del asignado para la incidencia |
|
||||
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá usando el dominio. |
|
||||
| `issueType` | string | Sí | Tipo de incidencia a crear \(p. ej., Tarea, Historia\) |
|
||||
| `labels` | array | No | Etiquetas para la incidencia \(array de nombres de etiquetas\) |
|
||||
| `duedate` | string | No | Fecha de vencimiento para la incidencia \(formato: AAAA-MM-DD\) |
|
||||
| `reporter` | string | No | ID de cuenta del informador para la incidencia |
|
||||
| `environment` | string | No | Información del entorno para la incidencia |
|
||||
| `customFieldId` | string | No | ID del campo personalizado \(p. ej., customfield_10001\) |
|
||||
| `customFieldValue` | string | No | Valor para el campo personalizado |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Marca de tiempo de la operación |
|
||||
| `issueKey` | string | Clave de la incidencia creada (p. ej., PROJ-123) |
|
||||
| `issueKey` | string | Clave de la incidencia creada \(p. ej., PROJ-123\) |
|
||||
| `summary` | string | Resumen de la incidencia |
|
||||
| `url` | string | URL de la incidencia creada |
|
||||
| `assigneeId` | string | ID de cuenta del usuario asignado \(si está asignado\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,6 +527,30 @@ Eliminar un observador de una incidencia de Jira
|
||||
| `issueKey` | string | Clave de incidencia |
|
||||
| `watcherAccountId` | string | ID de cuenta del observador eliminado |
|
||||
|
||||
### `jira_get_users`
|
||||
|
||||
Obtener usuarios de Jira. Si se proporciona un ID de cuenta, devuelve un solo usuario. De lo contrario, devuelve una lista de todos los usuarios.
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | string | Sí | Tu dominio de Jira \(p. ej., tuempresa.atlassian.net\) |
|
||||
| `accountId` | string | No | ID de cuenta opcional para obtener un usuario específico. Si no se proporciona, devuelve todos los usuarios. |
|
||||
| `startAt` | number | No | El índice del primer usuario a devolver \(para paginación, predeterminado: 0\) |
|
||||
| `maxResults` | number | No | Número máximo de usuarios a devolver \(predeterminado: 50\) |
|
||||
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá usando el dominio. |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Marca de tiempo de la operación |
|
||||
| `users` | json | Array de usuarios con accountId, displayName, emailAddress, estado activo y avatarUrls |
|
||||
| `total` | number | Número total de usuarios devueltos |
|
||||
| `startAt` | number | Índice de inicio de paginación |
|
||||
| `maxResults` | number | Máximo de resultados por página |
|
||||
|
||||
## Notas
|
||||
|
||||
- Categoría: `tools`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Crea, lee, actualiza, elimina e importa masivamente registros de ServiceNow
|
||||
description: Crear, leer, actualizar y eliminar registros de ServiceNow
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -10,23 +10,37 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[ServiceNow](https://www.servicenow.com/) es una potente plataforma en la nube diseñada para optimizar y automatizar la gestión de servicios de TI (ITSM), flujos de trabajo y procesos empresariales en toda tu organización. ServiceNow te permite gestionar incidencias, solicitudes, tareas, usuarios y más utilizando su amplia API.
|
||||
|
||||
Con ServiceNow, puedes:
|
||||
|
||||
- **Automatizar flujos de trabajo de TI**: crear, leer, actualizar y eliminar registros en cualquier tabla de ServiceNow, como incidencias, tareas, solicitudes de cambio y usuarios.
|
||||
- **Integrar sistemas**: conectar ServiceNow con tus otras herramientas y procesos para una automatización fluida.
|
||||
- **Mantener una única fuente de verdad**: mantener todos tus datos de servicio y operaciones organizados y accesibles.
|
||||
- **Impulsar la eficiencia operativa**: reducir el trabajo manual y mejorar la calidad del servicio con flujos de trabajo personalizables y automatización.
|
||||
|
||||
En Sim, la integración de ServiceNow permite que tus agentes interactúen directamente con tu instancia de ServiceNow como parte de sus flujos de trabajo. Los agentes pueden crear, leer, actualizar o eliminar registros en cualquier tabla de ServiceNow y aprovechar datos de tickets o usuarios para automatización y toma de decisiones sofisticadas. Esta integración conecta tu automatización de flujos de trabajo y operaciones de TI, permitiendo que tus agentes gestionen solicitudes de servicio, incidencias, usuarios y activos sin intervención manual. Al conectar Sim con ServiceNow, puedes automatizar tareas de gestión de servicios, mejorar los tiempos de respuesta y garantizar un acceso consistente y seguro a los datos de servicio vitales de tu organización.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Instrucciones de uso
|
||||
|
||||
Integra ServiceNow en tu flujo de trabajo. Puede crear, leer, actualizar y eliminar registros en cualquier tabla de ServiceNow (incidentes, tareas, usuarios, etc.). Admite operaciones de importación masiva para migración de datos y ETL.
|
||||
Integra ServiceNow en tu flujo de trabajo. Crea, lee, actualiza y elimina registros en cualquier tabla de ServiceNow, incluyendo incidencias, tareas, solicitudes de cambio, usuarios y más.
|
||||
|
||||
## Herramientas
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
Crea un nuevo registro en una tabla de ServiceNow
|
||||
Crear un nuevo registro en una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Sí | URL de la instancia de ServiceNow \(ej., https://instance.service-now.com\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla \(ej., incident, task, sys_user\) |
|
||||
| `instanceUrl` | string | Sí | URL de la instancia de ServiceNow \(p. ej., https://instance.service-now.com\) |
|
||||
| `username` | string | Sí | Nombre de usuario de ServiceNow |
|
||||
| `password` | string | Sí | Contraseña de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla \(p. ej., incident, task, sys_user\) |
|
||||
| `fields` | json | Sí | Campos a establecer en el registro \(objeto JSON\) |
|
||||
|
||||
#### Salida
|
||||
@@ -38,18 +52,19 @@ Crea un nuevo registro en una tabla de ServiceNow
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
Lee registros de una tabla de ServiceNow
|
||||
Leer registros de una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | URL de la instancia de ServiceNow \(detectada automáticamente desde OAuth si no se proporciona\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `instanceUrl` | string | Sí | URL de la instancia de ServiceNow \(p. ej., https://instance.service-now.com\) |
|
||||
| `username` | string | Sí | Nombre de usuario de ServiceNow |
|
||||
| `password` | string | Sí | Contraseña de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla |
|
||||
| `sysId` | string | No | sys_id específico del registro |
|
||||
| `number` | string | No | Número de registro \(ej., INC0010001\) |
|
||||
| `query` | string | No | Cadena de consulta codificada \(ej., "active=true^priority=1"\) |
|
||||
| `sysId` | string | No | sys_id del registro específico |
|
||||
| `number` | string | No | Número de registro \(p. ej., INC0010001\) |
|
||||
| `query` | string | No | Cadena de consulta codificada \(p. ej., "active=true^priority=1"\) |
|
||||
| `limit` | number | No | Número máximo de registros a devolver |
|
||||
| `fields` | string | No | Lista de campos separados por comas a devolver |
|
||||
|
||||
@@ -62,14 +77,15 @@ Lee registros de una tabla de ServiceNow
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
Actualizar un registro existente en una tabla de ServiceNow
|
||||
Actualiza un registro existente en una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | URL de la instancia de ServiceNow \(detectada automáticamente desde OAuth si no se proporciona\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `instanceUrl` | string | Sí | URL de la instancia de ServiceNow \(ej., https://instance.service-now.com\) |
|
||||
| `username` | string | Sí | Nombre de usuario de ServiceNow |
|
||||
| `password` | string | Sí | Contraseña de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla |
|
||||
| `sysId` | string | Sí | sys_id del registro a actualizar |
|
||||
| `fields` | json | Sí | Campos a actualizar \(objeto JSON\) |
|
||||
@@ -83,14 +99,15 @@ Actualizar un registro existente en una tabla de ServiceNow
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
Eliminar un registro de una tabla de ServiceNow
|
||||
Elimina un registro de una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | URL de la instancia de ServiceNow \(detectada automáticamente desde OAuth si no se proporciona\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `instanceUrl` | string | Sí | URL de la instancia de ServiceNow \(ej., https://instance.service-now.com\) |
|
||||
| `username` | string | Sí | Nombre de usuario de ServiceNow |
|
||||
| `password` | string | Sí | Contraseña de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla |
|
||||
| `sysId` | string | Sí | sys_id del registro a eliminar |
|
||||
|
||||
|
||||
@@ -37,16 +37,18 @@ Envía una solicitud de completado de chat a cualquier proveedor de LLM compatib
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `model` | string | Sí | El modelo a utilizar \(p. ej., gpt-4o, claude-sonnet-4-5, gemini-2.0-flash\) |
|
||||
| `model` | string | Sí | El modelo a utilizar \(ej., gpt-4o, claude-sonnet-4-5, gemini-2.0-flash\) |
|
||||
| `systemPrompt` | string | No | Prompt del sistema para establecer el comportamiento del asistente |
|
||||
| `context` | string | Sí | El mensaje del usuario o contexto para enviar al modelo |
|
||||
| `apiKey` | string | No | Clave API para el proveedor \(usa la clave de la plataforma si no se proporciona para modelos alojados\) |
|
||||
| `context` | string | Sí | El mensaje del usuario o contexto a enviar al modelo |
|
||||
| `apiKey` | string | No | Clave API del proveedor \(usa la clave de la plataforma si no se proporciona para modelos alojados\) |
|
||||
| `temperature` | number | No | Temperatura para la generación de respuestas \(0-2\) |
|
||||
| `maxTokens` | number | No | Tokens máximos en la respuesta |
|
||||
| `azureEndpoint` | string | No | URL del endpoint de Azure OpenAI |
|
||||
| `azureApiVersion` | string | No | Versión de la API de Azure OpenAI |
|
||||
| `vertexProject` | string | No | ID del proyecto de Google Cloud para Vertex AI |
|
||||
| `vertexLocation` | string | No | Ubicación de Google Cloud para Vertex AI \(por defecto us-central1\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
|
||||
@@ -111,26 +111,24 @@ Différents types de blocs produisent différentes structures de sortie. Voici c
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Original content passed through",
|
||||
"conditionResult": true,
|
||||
"selectedPath": {
|
||||
"blockId": "2acd9007-27e8-4510-a487-73d3b825e7c1",
|
||||
"blockType": "agent",
|
||||
"blockTitle": "Follow-up Agent"
|
||||
},
|
||||
"selectedConditionId": "condition-1"
|
||||
"selectedOption": "condition-1"
|
||||
}
|
||||
```
|
||||
|
||||
### Champs de sortie du bloc de condition
|
||||
|
||||
- **content** : le contenu original transmis
|
||||
- **conditionResult** : résultat booléen de l'évaluation de la condition
|
||||
- **selectedPath** : informations sur le chemin sélectionné
|
||||
- **blockId** : ID du bloc suivant dans le chemin sélectionné
|
||||
- **blockType** : type du bloc suivant
|
||||
- **blockTitle** : titre du bloc suivant
|
||||
- **selectedConditionId** : ID de la condition sélectionnée
|
||||
- **selectedOption** : ID de la condition sélectionnée
|
||||
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -89,15 +89,21 @@ Rédiger une demande Jira
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Oui | Votre domaine Jira (ex. : votreentreprise.atlassian.net) |
|
||||
| `projectId` | string | Oui | ID du projet pour la demande |
|
||||
| `summary` | string | Oui | Résumé de la demande |
|
||||
| `description` | string | Non | Description de la demande |
|
||||
| `priority` | string | Non | Priorité de la demande |
|
||||
| `assignee` | string | Non | Assigné de la demande |
|
||||
| `cloudId` | string | Non | ID Jira Cloud pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
|
||||
| `issueType` | string | Oui | Type de demande à créer (ex. : Tâche, Story) |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | chaîne | Oui | Votre domaine Jira \(ex. : votreentreprise.atlassian.net\) |
|
||||
| `projectId` | chaîne | Oui | ID du projet pour le ticket |
|
||||
| `summary` | chaîne | Oui | Résumé du ticket |
|
||||
| `description` | chaîne | Non | Description du ticket |
|
||||
| `priority` | chaîne | Non | ID ou nom de la priorité du ticket \(ex. : "10000" ou "Haute"\) |
|
||||
| `assignee` | chaîne | Non | ID de compte de l'assigné pour le ticket |
|
||||
| `cloudId` | chaîne | Non | ID Cloud Jira pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
|
||||
| `issueType` | chaîne | Oui | Type de ticket à créer \(ex. : tâche, story\) |
|
||||
| `labels` | tableau | Non | Étiquettes pour le ticket \(tableau de noms d'étiquettes\) |
|
||||
| `duedate` | chaîne | Non | Date d'échéance du ticket \(format : AAAA-MM-JJ\) |
|
||||
| `reporter` | chaîne | Non | ID de compte du rapporteur pour le ticket |
|
||||
| `environment` | chaîne | Non | Informations d'environnement pour le ticket |
|
||||
| `customFieldId` | chaîne | Non | ID du champ personnalisé \(ex. : customfield_10001\) |
|
||||
| `customFieldValue` | chaîne | Non | Valeur pour le champ personnalisé |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -107,6 +113,7 @@ Rédiger une demande Jira
|
||||
| `issueKey` | chaîne | Clé du ticket créé \(ex. : PROJ-123\) |
|
||||
| `summary` | chaîne | Résumé du ticket |
|
||||
| `url` | chaîne | URL vers le ticket créé |
|
||||
| `assigneeId` | chaîne | ID de compte de l'utilisateur assigné \(si assigné\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,7 +527,31 @@ Supprimer un observateur d'un ticket Jira
|
||||
| `issueKey` | string | Clé du ticket |
|
||||
| `watcherAccountId` | string | ID du compte observateur supprimé |
|
||||
|
||||
## Notes
|
||||
### `jira_get_users`
|
||||
|
||||
Récupère les utilisateurs Jira. Si un ID de compte est fourni, renvoie un seul utilisateur. Sinon, renvoie une liste de tous les utilisateurs.
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | chaîne | Oui | Votre domaine Jira \(ex. : votreentreprise.atlassian.net\) |
|
||||
| `accountId` | chaîne | Non | ID de compte optionnel pour obtenir un utilisateur spécifique. S'il n'est pas fourni, renvoie tous les utilisateurs. |
|
||||
| `startAt` | nombre | Non | L'index du premier utilisateur à renvoyer \(pour la pagination, par défaut : 0\) |
|
||||
| `maxResults` | nombre | Non | Nombre maximum d'utilisateurs à renvoyer \(par défaut : 50\) |
|
||||
| `cloudId` | chaîne | Non | ID Cloud Jira pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | chaîne | Horodatage de l'opération |
|
||||
| `users` | json | Tableau d'utilisateurs avec accountId, displayName, emailAddress, statut actif et avatarUrls |
|
||||
| `total` | nombre | Nombre total d'utilisateurs renvoyés |
|
||||
| `startAt` | nombre | Index de début de pagination |
|
||||
| `maxResults` | nombre | Nombre maximum de résultats par page |
|
||||
|
||||
## Remarques
|
||||
|
||||
- Catégorie : `tools`
|
||||
- Type : `jira`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Créer, lire, mettre à jour, supprimer et importer en masse des
|
||||
enregistrements ServiceNow
|
||||
description: Créer, lire, mettre à jour et supprimer des enregistrements ServiceNow
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -11,9 +10,22 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[ServiceNow](https://www.servicenow.com/) est une plateforme cloud puissante conçue pour rationaliser et automatiser la gestion des services informatiques (ITSM), les workflows et les processus métier au sein de votre organisation. ServiceNow vous permet de gérer les incidents, les demandes, les tâches, les utilisateurs et bien plus encore grâce à son API étendue.
|
||||
|
||||
Avec ServiceNow, vous pouvez :
|
||||
|
||||
- **Automatiser les workflows informatiques** : créer, lire, mettre à jour et supprimer des enregistrements dans n'importe quelle table ServiceNow, tels que les incidents, les tâches, les demandes de changement et les utilisateurs.
|
||||
- **Intégrer les systèmes** : connecter ServiceNow avec vos autres outils et processus pour une automatisation transparente.
|
||||
- **Maintenir une source unique de vérité** : garder toutes vos données de service et d'exploitation organisées et accessibles.
|
||||
- **Améliorer l'efficacité opérationnelle** : réduire le travail manuel et améliorer la qualité du service grâce à des workflows personnalisables et à l'automatisation.
|
||||
|
||||
Dans Sim, l'intégration ServiceNow permet à vos agents d'interagir directement avec votre instance ServiceNow dans le cadre de leurs workflows. Les agents peuvent créer, lire, mettre à jour ou supprimer des enregistrements dans n'importe quelle table ServiceNow et exploiter les données de tickets ou d'utilisateurs pour une automatisation et une prise de décision sophistiquées. Cette intégration relie votre automatisation de workflow et vos opérations informatiques, permettant à vos agents de gérer les demandes de service, les incidents, les utilisateurs et les actifs sans intervention manuelle. En connectant Sim avec ServiceNow, vous pouvez automatiser les tâches de gestion des services, améliorer les temps de réponse et garantir un accès cohérent et sécurisé aux données de service vitales de votre organisation.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Instructions d'utilisation
|
||||
|
||||
Intégrez ServiceNow dans votre flux de travail. Permet de créer, lire, mettre à jour et supprimer des enregistrements dans n'importe quelle table ServiceNow (incidents, tâches, utilisateurs, etc.). Prend en charge les opérations d'importation en masse pour la migration de données et l'ETL.
|
||||
Intégrez ServiceNow dans votre workflow. Créez, lisez, mettez à jour et supprimez des enregistrements dans n'importe quelle table ServiceNow, y compris les incidents, les tâches, les demandes de changement, les utilisateurs et bien plus encore.
|
||||
|
||||
## Outils
|
||||
|
||||
@@ -25,10 +37,11 @@ Créer un nouvel enregistrement dans une table ServiceNow
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Oui | URL de l'instance ServiceNow \(par exemple, https://instance.service-now.com\) |
|
||||
| `credential` | string | Non | ID d'identification OAuth ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table \(par exemple, incident, task, sys_user\) |
|
||||
| `fields` | json | Oui | Champs à définir sur l'enregistrement \(objet JSON\) |
|
||||
| `instanceUrl` | string | Oui | URL de l'instance ServiceNow (par ex., https://instance.service-now.com) |
|
||||
| `username` | string | Oui | Nom d'utilisateur ServiceNow |
|
||||
| `password` | string | Oui | Mot de passe ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table (par ex., incident, task, sys_user) |
|
||||
| `fields` | json | Oui | Champs à définir sur l'enregistrement (objet JSON) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -45,20 +58,21 @@ Lire des enregistrements d'une table ServiceNow
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Non | URL de l'instance ServiceNow \(détectée automatiquement depuis OAuth si non fournie\) |
|
||||
| `credential` | string | Non | ID d'identification OAuth ServiceNow |
|
||||
| `instanceUrl` | string | Oui | URL de l'instance ServiceNow (par ex., https://instance.service-now.com) |
|
||||
| `username` | string | Oui | Nom d'utilisateur ServiceNow |
|
||||
| `password` | string | Oui | Mot de passe ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table |
|
||||
| `sysId` | string | Non | sys_id spécifique de l'enregistrement |
|
||||
| `number` | string | Non | Numéro d'enregistrement \(par exemple, INC0010001\) |
|
||||
| `query` | string | Non | Chaîne de requête encodée \(par exemple, "active=true^priority=1"\) |
|
||||
| `sysId` | string | Non | sys_id d'enregistrement spécifique |
|
||||
| `number` | string | Non | Numéro d'enregistrement (par ex., INC0010001) |
|
||||
| `query` | string | Non | Chaîne de requête encodée (par ex., "active=true^priority=1") |
|
||||
| `limit` | number | Non | Nombre maximum d'enregistrements à retourner |
|
||||
| `fields` | string | Non | Liste de champs séparés par des virgules à retourner |
|
||||
| `fields` | string | Non | Liste de champs à retourner, séparés par des virgules |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | Tableau des enregistrements ServiceNow |
|
||||
| `records` | array | Tableau d'enregistrements ServiceNow |
|
||||
| `metadata` | json | Métadonnées de l'opération |
|
||||
|
||||
### `servicenow_update_record`
|
||||
@@ -69,11 +83,12 @@ Mettre à jour un enregistrement existant dans une table ServiceNow
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Non | URL de l'instance ServiceNow (détectée automatiquement depuis OAuth si non fournie) |
|
||||
| `credential` | string | Non | ID des identifiants OAuth ServiceNow |
|
||||
| `instanceUrl` | string | Oui | URL de l'instance ServiceNow \(par exemple, https://instance.service-now.com\) |
|
||||
| `username` | string | Oui | Nom d'utilisateur ServiceNow |
|
||||
| `password` | string | Oui | Mot de passe ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table |
|
||||
| `sysId` | string | Oui | sys_id de l'enregistrement à mettre à jour |
|
||||
| `fields` | json | Oui | Champs à mettre à jour (objet JSON) |
|
||||
| `fields` | json | Oui | Champs à mettre à jour \(objet JSON\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -90,8 +105,9 @@ Supprimer un enregistrement d'une table ServiceNow
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Non | URL de l'instance ServiceNow (détectée automatiquement depuis OAuth si non fournie) |
|
||||
| `credential` | string | Non | ID des identifiants OAuth ServiceNow |
|
||||
| `instanceUrl` | string | Oui | URL de l'instance ServiceNow \(par exemple, https://instance.service-now.com\) |
|
||||
| `username` | string | Oui | Nom d'utilisateur ServiceNow |
|
||||
| `password` | string | Oui | Mot de passe ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table |
|
||||
| `sysId` | string | Oui | sys_id de l'enregistrement à supprimer |
|
||||
|
||||
@@ -102,7 +118,7 @@ Supprimer un enregistrement d'une table ServiceNow
|
||||
| `success` | boolean | Indique si la suppression a réussi |
|
||||
| `metadata` | json | Métadonnées de l'opération |
|
||||
|
||||
## Notes
|
||||
## Remarques
|
||||
|
||||
- Catégorie : `tools`
|
||||
- Type : `servicenow`
|
||||
|
||||
@@ -37,16 +37,18 @@ Envoyez une requête de complétion de chat à n'importe quel fournisseur de LLM
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `model` | chaîne | Oui | Le modèle à utiliser (ex. : gpt-4o, claude-sonnet-4-5, gemini-2.0-flash) |
|
||||
| `systemPrompt` | chaîne | Non | Instruction système pour définir le comportement de l'assistant |
|
||||
| `context` | chaîne | Oui | Le message utilisateur ou le contexte à envoyer au modèle |
|
||||
| `apiKey` | chaîne | Non | Clé API pour le fournisseur (utilise la clé de plateforme si non fournie pour les modèles hébergés) |
|
||||
| `temperature` | nombre | Non | Température pour la génération de réponse (0-2) |
|
||||
| `maxTokens` | nombre | Non | Nombre maximum de tokens dans la réponse |
|
||||
| `azureEndpoint` | chaîne | Non | URL du point de terminaison Azure OpenAI |
|
||||
| `azureApiVersion` | chaîne | Non | Version de l'API Azure OpenAI |
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `model` | string | Oui | Le modèle à utiliser \(par exemple, gpt-4o, claude-sonnet-4-5, gemini-2.0-flash\) |
|
||||
| `systemPrompt` | string | Non | Prompt système pour définir le comportement de l'assistant |
|
||||
| `context` | string | Oui | Le message utilisateur ou le contexte à envoyer au modèle |
|
||||
| `apiKey` | string | Non | Clé API pour le fournisseur \(utilise la clé de la plateforme si non fournie pour les modèles hébergés\) |
|
||||
| `temperature` | number | Non | Température pour la génération de réponse \(0-2\) |
|
||||
| `maxTokens` | number | Non | Nombre maximum de tokens dans la réponse |
|
||||
| `azureEndpoint` | string | Non | URL du point de terminaison Azure OpenAI |
|
||||
| `azureApiVersion` | string | Non | Version de l'API Azure OpenAI |
|
||||
| `vertexProject` | string | Non | ID du projet Google Cloud pour Vertex AI |
|
||||
| `vertexLocation` | string | Non | Emplacement Google Cloud pour Vertex AI \(par défaut us-central1\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
|
||||
@@ -110,26 +110,24 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Original content passed through",
|
||||
"conditionResult": true,
|
||||
"selectedPath": {
|
||||
"blockId": "2acd9007-27e8-4510-a487-73d3b825e7c1",
|
||||
"blockType": "agent",
|
||||
"blockTitle": "Follow-up Agent"
|
||||
},
|
||||
"selectedConditionId": "condition-1"
|
||||
"selectedOption": "condition-1"
|
||||
}
|
||||
```
|
||||
|
||||
### 条件ブロックの出力フィールド
|
||||
|
||||
- **content**: そのまま渡される元のコンテンツ
|
||||
- **conditionResult**: 条件評価の真偽値結果
|
||||
- **selectedPath**: 選択されたパスに関する情報
|
||||
- **blockId**: 選択されたパスの次のブロックのID
|
||||
- **blockType**: 次のブロックのタイプ
|
||||
- **blockTitle**: 次のブロックのタイトル
|
||||
- **selectedConditionId**: 選択された条件のID
|
||||
- **selectedOption**: 選択された条件のID
|
||||
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -94,10 +94,16 @@ Jira課題を作成する
|
||||
| `projectId` | string | はい | 課題のプロジェクトID |
|
||||
| `summary` | string | はい | 課題の要約 |
|
||||
| `description` | string | いいえ | 課題の説明 |
|
||||
| `priority` | string | いいえ | 課題の優先度 |
|
||||
| `assignee` | string | いいえ | 課題の担当者 |
|
||||
| `priority` | string | いいえ | 課題の優先度IDまたは名前(例:「10000」または「高」) |
|
||||
| `assignee` | string | いいえ | 課題の担当者アカウントID |
|
||||
| `cloudId` | string | いいえ | インスタンスのJira Cloud ID。提供されない場合、ドメインを使用して取得されます。 |
|
||||
| `issueType` | string | はい | 作成する課題のタイプ(例:タスク、ストーリー) |
|
||||
| `labels` | array | いいえ | 課題のラベル(ラベル名の配列) |
|
||||
| `duedate` | string | いいえ | 課題の期限(形式:YYYY-MM-DD) |
|
||||
| `reporter` | string | いいえ | 課題の報告者アカウントID |
|
||||
| `environment` | string | いいえ | 課題の環境情報 |
|
||||
| `customFieldId` | string | いいえ | カスタムフィールドID(例:customfield_10001) |
|
||||
| `customFieldValue` | string | いいえ | カスタムフィールドの値 |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -106,7 +112,8 @@ Jira課題を作成する
|
||||
| `ts` | string | 操作のタイムスタンプ |
|
||||
| `issueKey` | string | 作成された課題キー(例:PROJ-123) |
|
||||
| `summary` | string | 課題の要約 |
|
||||
| `url` | string | 作成された課題へのURL |
|
||||
| `url` | string | 作成された課題のURL |
|
||||
| `assigneeId` | string | 割り当てられたユーザーのアカウントID(割り当てられている場合) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,7 +527,31 @@ Jira課題からウォッチャーを削除する
|
||||
| `issueKey` | string | 課題キー |
|
||||
| `watcherAccountId` | string | 削除されたウォッチャーのアカウントID |
|
||||
|
||||
## 注意事項
|
||||
### `jira_get_users`
|
||||
|
||||
- カテゴリー: `tools`
|
||||
- タイプ: `jira`
|
||||
Jiraユーザーを取得します。アカウントIDが提供された場合、単一のユーザーを返します。それ以外の場合、すべてのユーザーのリストを返します。
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | はい | あなたのJiraドメイン(例:yourcompany.atlassian.net) |
|
||||
| `accountId` | string | いいえ | 特定のユーザーを取得するためのオプションのアカウントID。提供されない場合、すべてのユーザーを返します。 |
|
||||
| `startAt` | number | いいえ | 返す最初のユーザーのインデックス(ページネーション用、デフォルト:0) |
|
||||
| `maxResults` | number | いいえ | 返すユーザーの最大数(デフォルト:50) |
|
||||
| `cloudId` | string | いいえ | インスタンスのJira Cloud ID。提供されない場合、ドメインを使用して取得されます。 |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | 操作のタイムスタンプ |
|
||||
| `users` | json | accountId、displayName、emailAddress、activeステータス、avatarUrlsを含むユーザーの配列 |
|
||||
| `total` | number | 返されたユーザーの総数 |
|
||||
| `startAt` | number | ページネーション開始インデックス |
|
||||
| `maxResults` | number | ページあたりの最大結果数 |
|
||||
|
||||
## 注記
|
||||
|
||||
- カテゴリ:`tools`
|
||||
- タイプ:`jira`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: ServiceNowレコードの作成、読み取り、更新、削除、一括インポート
|
||||
description: ServiceNowレコードの作成、読み取り、更新、削除
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -10,9 +10,22 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[ServiceNow](https://www.servicenow.com/)は、組織全体のITサービス管理(ITSM)、ワークフロー、ビジネスプロセスを効率化し自動化するために設計された強力なクラウドプラットフォームです。ServiceNowを使用すると、広範なAPIを使用してインシデント、リクエスト、タスク、ユーザーなどを管理できます。
|
||||
|
||||
ServiceNowでは、次のことができます。
|
||||
|
||||
- **ITワークフローの自動化**: インシデント、タスク、変更リクエスト、ユーザーなど、任意のServiceNowテーブルのレコードを作成、読み取り、更新、削除します。
|
||||
- **システムの統合**: ServiceNowを他のツールやプロセスと接続して、シームレスな自動化を実現します。
|
||||
- **単一の信頼できる情報源の維持**: すべてのサービスおよび運用データを整理してアクセス可能な状態に保ちます。
|
||||
- **運用効率の向上**: カスタマイズ可能なワークフローと自動化により、手作業を削減し、サービス品質を向上させます。
|
||||
|
||||
Simでは、ServiceNow統合により、エージェントがワークフローの一部としてServiceNowインスタンスと直接やり取りできるようになります。エージェントは、任意のServiceNowテーブルのレコードを作成、読み取り、更新、削除でき、チケットやユーザーデータを活用して高度な自動化と意思決定を行うことができます。この統合により、ワークフロー自動化とIT運用が橋渡しされ、エージェントは手動介入なしでサービスリクエスト、インシデント、ユーザー、資産を管理できるようになります。SimとServiceNowを接続することで、サービス管理タスクを自動化し、応答時間を改善し、組織の重要なサービスデータへの一貫性のある安全なアクセスを確保できます。
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## 使用方法
|
||||
|
||||
ServiceNowをワークフローに統合します。任意のServiceNowテーブル(インシデント、タスク、ユーザーなど)のレコードを作成、読み取り、更新、削除できます。データ移行とETLのための一括インポート操作をサポートします。
|
||||
ServiceNowをワークフローに統合します。インシデント、タスク、変更リクエスト、ユーザーなど、任意のServiceNowテーブルのレコードを作成、読み取り、更新、削除します。
|
||||
|
||||
## ツール
|
||||
|
||||
@@ -24,10 +37,11 @@ ServiceNowテーブルに新しいレコードを作成
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | はい | ServiceNowインスタンスURL(例:https://instance.service-now.com) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `tableName` | string | はい | テーブル名(例:incident、task、sys_user) |
|
||||
| `fields` | json | はい | レコードに設定するフィールド(JSONオブジェクト) |
|
||||
| `instanceUrl` | string | はい | ServiceNowインスタンスURL(例: https://instance.service-now.com) |
|
||||
| `username` | string | はい | ServiceNowユーザー名 |
|
||||
| `password` | string | はい | ServiceNowパスワード |
|
||||
| `tableName` | string | はい | テーブル名(例: incident、task、sys_user) |
|
||||
| `fields` | json | はい | レコードに設定するフィールド(JSONオブジェクト) |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -38,19 +52,20 @@ ServiceNowテーブルに新しいレコードを作成
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
ServiceNowテーブルからレコードを読み取り
|
||||
ServiceNowテーブルからレコードを読み取ります
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | いいえ | ServiceNowインスタンスURL(指定されていない場合はOAuthから自動検出) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `instanceUrl` | string | はい | ServiceNowインスタンスURL(例: https://instance.service-now.com) |
|
||||
| `username` | string | はい | ServiceNowユーザー名 |
|
||||
| `password` | string | はい | ServiceNowパスワード |
|
||||
| `tableName` | string | はい | テーブル名 |
|
||||
| `sysId` | string | いいえ | 特定のレコードsys_id |
|
||||
| `number` | string | いいえ | レコード番号(例:INC0010001) |
|
||||
| `query` | string | いいえ | エンコードされたクエリ文字列(例:"active=true^priority=1") |
|
||||
| `limit` | number | いいえ | 返す最大レコード数 |
|
||||
| `sysId` | string | いいえ | 特定のレコードのsys_id |
|
||||
| `number` | string | いいえ | レコード番号(例: INC0010001) |
|
||||
| `query` | string | いいえ | エンコードされたクエリ文字列(例: "active=true^priority=1") |
|
||||
| `limit` | number | いいえ | 返すレコードの最大数 |
|
||||
| `fields` | string | いいえ | 返すフィールドのカンマ区切りリスト |
|
||||
|
||||
#### 出力
|
||||
@@ -62,17 +77,18 @@ ServiceNowテーブルからレコードを読み取り
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
ServiceNowテーブル内の既存のレコードを更新します
|
||||
ServiceNowテーブル内の既存のレコードを更新
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | いいえ | ServiceNowインスタンスURL(指定されていない場合はOAuthから自動検出) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `instanceUrl` | string | はい | ServiceNowインスタンスURL(例:https://instance.service-now.com) |
|
||||
| `username` | string | はい | ServiceNowユーザー名 |
|
||||
| `password` | string | はい | ServiceNowパスワード |
|
||||
| `tableName` | string | はい | テーブル名 |
|
||||
| `sysId` | string | はい | 更新するレコードのsys_id |
|
||||
| `fields` | json | はい | 更新するフィールド(JSONオブジェクト) |
|
||||
| `fields` | json | はい | 更新するフィールド(JSONオブジェクト) |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -83,14 +99,15 @@ ServiceNowテーブル内の既存のレコードを更新します
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
ServiceNowテーブルからレコードを削除します
|
||||
ServiceNowテーブルからレコードを削除
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | いいえ | ServiceNowインスタンスURL(指定されていない場合はOAuthから自動検出) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `instanceUrl` | string | はい | ServiceNowインスタンスURL(例:https://instance.service-now.com) |
|
||||
| `username` | string | はい | ServiceNowユーザー名 |
|
||||
| `password` | string | はい | ServiceNowパスワード |
|
||||
| `tableName` | string | はい | テーブル名 |
|
||||
| `sysId` | string | はい | 削除するレコードのsys_id |
|
||||
|
||||
@@ -101,7 +118,7 @@ ServiceNowテーブルからレコードを削除します
|
||||
| `success` | boolean | 削除が成功したかどうか |
|
||||
| `metadata` | json | 操作メタデータ |
|
||||
|
||||
## 注記
|
||||
## 注意事項
|
||||
|
||||
- カテゴリー: `tools`
|
||||
- カテゴリ: `tools`
|
||||
- タイプ: `servicenow`
|
||||
|
||||
@@ -42,11 +42,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
| `model` | string | はい | 使用するモデル(例:gpt-4o、claude-sonnet-4-5、gemini-2.0-flash) |
|
||||
| `systemPrompt` | string | いいえ | アシスタントの動作を設定するシステムプロンプト |
|
||||
| `context` | string | はい | モデルに送信するユーザーメッセージまたはコンテキスト |
|
||||
| `apiKey` | string | いいえ | プロバイダーのAPIキー(ホストされたモデルの場合、提供されなければプラットフォームキーを使用) |
|
||||
| `apiKey` | string | いいえ | プロバイダーのAPIキー(ホストされたモデルの場合、提供されない場合はプラットフォームキーを使用) |
|
||||
| `temperature` | number | いいえ | レスポンス生成の温度(0-2) |
|
||||
| `maxTokens` | number | いいえ | レスポンスの最大トークン数 |
|
||||
| `azureEndpoint` | string | いいえ | Azure OpenAIエンドポイントURL |
|
||||
| `azureApiVersion` | string | いいえ | Azure OpenAI APIバージョン |
|
||||
| `vertexProject` | string | いいえ | Vertex AI用のGoogle CloudプロジェクトID |
|
||||
| `vertexLocation` | string | いいえ | Vertex AI用のGoogle Cloudロケーション(デフォルトはus-central1) |
|
||||
|
||||
#### 出力
|
||||
|
||||
|
||||
@@ -110,26 +110,24 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Original content passed through",
|
||||
"conditionResult": true,
|
||||
"selectedPath": {
|
||||
"blockId": "2acd9007-27e8-4510-a487-73d3b825e7c1",
|
||||
"blockType": "agent",
|
||||
"blockTitle": "Follow-up Agent"
|
||||
},
|
||||
"selectedConditionId": "condition-1"
|
||||
"selectedOption": "condition-1"
|
||||
}
|
||||
```
|
||||
|
||||
### 条件模块输出字段
|
||||
|
||||
- **content**:传递的原始内容
|
||||
- **conditionResult**:条件评估的布尔结果
|
||||
- **selectedPath**:关于选定路径的信息
|
||||
- **blockId**:选定路径中下一个模块的 ID
|
||||
- **blockType**:下一个模块的类型
|
||||
- **blockTitle**:下一个模块的标题
|
||||
- **selectedConditionId**:选定条件的 ID
|
||||
- **conditionResult**:条件判断的布尔值结果
|
||||
- **selectedPath**:所选路径的信息
|
||||
- **blockId**:所选路径下一个区块的 ID
|
||||
- **blockType**:下一个区块的类型
|
||||
- **blockTitle**:下一个区块的标题
|
||||
- **selectedOption**:所选条件的 ID
|
||||
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -91,13 +91,19 @@ Jira 的主要功能包括:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | 字符串 | 是 | 您的 Jira 域名 \(例如:yourcompany.atlassian.net\) |
|
||||
| `projectId` | 字符串 | 是 | 问题的项目 ID |
|
||||
| `summary` | 字符串 | 是 | 问题的摘要 |
|
||||
| `description` | 字符串 | 否 | 问题的描述 |
|
||||
| `priority` | 字符串 | 否 | 问题的优先级 |
|
||||
| `assignee` | 字符串 | 否 | 问题的负责人 |
|
||||
| `cloudId` | 字符串 | 否 | 实例的 Jira 云 ID。如果未提供,将使用域名获取。 |
|
||||
| `issueType` | 字符串 | 是 | 要创建的问题类型 \(例如:任务、故事\) |
|
||||
| `projectId` | 字符串 | 是 | 问题所属项目 ID |
|
||||
| `summary` | 字符串 | 是 | 问题摘要 |
|
||||
| `description` | 字符串 | 否 | 问题描述 |
|
||||
| `priority` | 字符串 | 否 | 问题优先级 ID 或名称 \(例如:“10000”或“High”\) |
|
||||
| `assignee` | 字符串 | 否 | 问题负责人账户 ID |
|
||||
| `cloudId` | 字符串 | 否 | 实例的 Jira Cloud ID。如果未提供,将使用域名获取。 |
|
||||
| `issueType` | 字符串 | 是 | 要创建的问题类型 \(例如:Task、Story\) |
|
||||
| `labels` | 数组 | 否 | 问题标签 \(标签名称数组\) |
|
||||
| `duedate` | 字符串 | 否 | 问题截止日期 \(格式:YYYY-MM-DD\) |
|
||||
| `reporter` | 字符串 | 否 | 问题报告人账户 ID |
|
||||
| `environment` | 字符串 | 否 | 问题环境信息 |
|
||||
| `customFieldId` | 字符串 | 否 | 自定义字段 ID \(例如:customfield_10001\) |
|
||||
| `customFieldValue` | 字符串 | 否 | 自定义字段的值 |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -107,6 +113,7 @@ Jira 的主要功能包括:
|
||||
| `issueKey` | 字符串 | 创建的问题键 \(例如:PROJ-123\) |
|
||||
| `summary` | 字符串 | 问题摘要 |
|
||||
| `url` | 字符串 | 创建的问题的 URL |
|
||||
| `assigneeId` | 字符串 | 已分配用户的账户 ID(如已分配) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,7 +527,31 @@ Jira 的主要功能包括:
|
||||
| `issueKey` | string | 问题键 |
|
||||
| `watcherAccountId` | string | 移除的观察者账户 ID |
|
||||
|
||||
## 注意事项
|
||||
### `jira_get_users`
|
||||
|
||||
- 类别: `tools`
|
||||
- 类型: `jira`
|
||||
获取 Jira 用户。如果提供了账户 ID,则返回单个用户,否则返回所有用户的列表。
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | 字符串 | 是 | 您的 Jira 域名 \(例如:yourcompany.atlassian.net\) |
|
||||
| `accountId` | 字符串 | 否 | 可选账户 ID,用于获取特定用户。如果未提供,则返回所有用户。 |
|
||||
| `startAt` | 数字 | 否 | 要返回的第一个用户的索引 \(用于分页,默认值:0\) |
|
||||
| `maxResults` | 数字 | 否 | 要返回的最大用户数 \(默认值:50\) |
|
||||
| `cloudId` | 字符串 | 否 | 实例的 Jira Cloud ID。如果未提供,将使用域名获取。 |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | 字符串 | 操作的时间戳 |
|
||||
| `users` | json | 用户数组,包含 accountId、displayName、emailAddress、active 状态和 avatarUrls |
|
||||
| `total` | 数字 | 返回的用户总数 |
|
||||
| `startAt` | 数字 | 分页起始索引 |
|
||||
| `maxResults` | 数字 | 每页最大结果数 |
|
||||
|
||||
## 备注
|
||||
|
||||
- 分类:`tools`
|
||||
- 类型:`jira`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: 创建、读取、更新、删除及批量导入 ServiceNow 记录
|
||||
description: 创建、读取、更新和删除 ServiceNow 记录
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -10,9 +10,22 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[ServiceNow](https://www.servicenow.com/) 是一款强大的云平台,旨在简化和自动化 IT 服务管理(ITSM)、工作流以及企业各类业务流程。ServiceNow 让您能够通过其强大的 API 管理事件、请求、任务、用户等多种内容。
|
||||
|
||||
使用 ServiceNow,您可以:
|
||||
|
||||
- **自动化 IT 工作流**:在任意 ServiceNow 表中创建、读取、更新和删除记录,如事件、任务、变更请求和用户等。
|
||||
- **集成系统**:将 ServiceNow 与您的其他工具和流程连接,实现无缝自动化。
|
||||
- **维护单一数据源**:让所有服务和运营数据井然有序,便于访问。
|
||||
- **提升运营效率**:通过可定制的工作流和自动化,减少手动操作,提高服务质量。
|
||||
|
||||
在 Sim 中,ServiceNow 集成让您的代理能够在工作流中直接与 ServiceNow 实例交互。代理可以在任意 ServiceNow 表中创建、读取、更新或删除记录,并利用工单或用户数据实现复杂的自动化和决策。这一集成将您的工作流自动化与 IT 运维无缝衔接,使代理能够自动化管理服务请求、事件、用户和资产,无需人工干预。通过将 Sim 与 ServiceNow 连接,您可以自动化服务管理任务、提升响应速度,并确保对组织关键服务数据的持续、安全访问。
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## 使用说明
|
||||
|
||||
将 ServiceNow 集成到您的工作流程中。可在任意 ServiceNow 表(如事件、任务、用户等)中创建、读取、更新和删除记录。支持批量导入操作,便于数据迁移和 ETL。
|
||||
将 ServiceNow 集成到您的工作流中。在任意 ServiceNow 表(包括事件、任务、变更请求、用户等)中创建、读取、更新和删除记录。
|
||||
|
||||
## 工具
|
||||
|
||||
@@ -22,16 +35,17 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| 参数 | 类型 | 是否必填 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 是 | ServiceNow 实例 URL(例如:https://instance.service-now.com) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `username` | string | 是 | ServiceNow 用户名 |
|
||||
| `password` | string | 是 | ServiceNow 密码 |
|
||||
| `tableName` | string | 是 | 表名(例如:incident、task、sys_user) |
|
||||
| `fields` | json | 是 | 要设置在记录上的字段(JSON 对象) |
|
||||
| `fields` | json | 是 | 记录中要设置的字段(JSON 对象) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | 创建的 ServiceNow 记录,包含 sys_id 及其他字段 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
@@ -42,10 +56,11 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| 参数 | 类型 | 是否必填 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 否 | ServiceNow 实例 URL(如未提供,将通过 OAuth 自动检测) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `instanceUrl` | string | 是 | ServiceNow 实例 URL(例如:https://instance.service-now.com) |
|
||||
| `username` | string | 是 | ServiceNow 用户名 |
|
||||
| `password` | string | 是 | ServiceNow 密码 |
|
||||
| `tableName` | string | 是 | 表名 |
|
||||
| `sysId` | string | 否 | 指定记录 sys_id |
|
||||
| `number` | string | 否 | 记录编号(例如:INC0010001) |
|
||||
@@ -55,7 +70,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | ServiceNow 记录数组 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
@@ -66,17 +81,18 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 是否必填 | 描述 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 否 | ServiceNow 实例 URL(如果未提供,将通过 OAuth 自动检测) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `instanceUrl` | string | 是 | ServiceNow 实例 URL(例如:https://instance.service-now.com) |
|
||||
| `username` | string | 是 | ServiceNow 用户名 |
|
||||
| `password` | string | 是 | ServiceNow 密码 |
|
||||
| `tableName` | string | 是 | 表名 |
|
||||
| `sysId` | string | 是 | 要更新的记录 sys_id |
|
||||
| `fields` | json | 是 | 要更新的字段(JSON 对象) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | 已更新的 ServiceNow 记录 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
@@ -87,10 +103,11 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 是否必填 | 描述 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 否 | ServiceNow 实例 URL(如果未提供,将通过 OAuth 自动检测) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `instanceUrl` | string | 是 | ServiceNow 实例 URL(例如:https://instance.service-now.com) |
|
||||
| `username` | string | 是 | ServiceNow 用户名 |
|
||||
| `password` | string | 是 | ServiceNow 密码 |
|
||||
| `tableName` | string | 是 | 表名 |
|
||||
| `sysId` | string | 是 | 要删除的记录 sys_id |
|
||||
|
||||
@@ -101,7 +118,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
| `success` | boolean | 删除是否成功 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
|
||||
## 注意事项
|
||||
## 备注
|
||||
|
||||
- 分类:`tools`
|
||||
- 类型:`servicenow`
|
||||
|
||||
@@ -37,16 +37,18 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `model` | string | 是 | 要使用的模型 \(例如,gpt-4o、claude-sonnet-4-5、gemini-2.0-flash\) |
|
||||
| `systemPrompt` | string | 否 | 设置助手行为的系统提示 |
|
||||
| `context` | string | 是 | 要发送给模型的用户消息或上下文 |
|
||||
| `apiKey` | string | 否 | 提供商的 API 密钥 \(如果未为托管模型提供,则使用平台密钥\) |
|
||||
| `temperature` | number | 否 | 响应生成的温度 \(0-2\) |
|
||||
| `maxTokens` | number | 否 | 响应的最大令牌数 |
|
||||
| `azureEndpoint` | string | 否 | Azure OpenAI 端点 URL |
|
||||
| `model` | string | 是 | 要使用的模型(例如 gpt-4o、claude-sonnet-4-5、gemini-2.0-flash) |
|
||||
| `systemPrompt` | string | 否 | 设置助手行为的 system prompt |
|
||||
| `context` | string | 是 | 发送给模型的用户消息或上下文 |
|
||||
| `apiKey` | string | 否 | 提供方的 API key(如未提供,托管模型将使用平台密钥) |
|
||||
| `temperature` | number | 否 | 响应生成的 temperature(0-2) |
|
||||
| `maxTokens` | number | 否 | 响应中的最大 tokens 数 |
|
||||
| `azureEndpoint` | string | 否 | Azure OpenAI endpoint URL |
|
||||
| `azureApiVersion` | string | 否 | Azure OpenAI API 版本 |
|
||||
| `vertexProject` | string | 否 | Vertex AI 的 Google Cloud 项目 ID |
|
||||
| `vertexLocation` | string | 否 | Vertex AI 的 Google Cloud 区域(默认为 us-central1) |
|
||||
|
||||
#### 输出
|
||||
|
||||
|
||||
@@ -557,7 +557,7 @@ checksums:
|
||||
content/8: 6325adefb6e1520835225285b18b6a45
|
||||
content/9: b7fa85fce9c7476fe132df189e27dac1
|
||||
content/10: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/11: 985f435f721b00df4d13fa0a5552684c
|
||||
content/11: 7ad14ccfe548588081626cfe769ad492
|
||||
content/12: bcadfc362b69078beee0088e5936c98b
|
||||
content/13: 6af66efd0da20944a87fdb8d9defa358
|
||||
content/14: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
@@ -2521,9 +2521,9 @@ checksums:
|
||||
content/22: ef92d95455e378abe4d27a1cdc5e1aed
|
||||
content/23: febd6019055f3754953fd93395d0dbf2
|
||||
content/24: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/25: 7ef3f388e5ee9346bac54c771d825f40
|
||||
content/25: caf6acbe2a4495ca055cb9006ce47250
|
||||
content/26: bcadfc362b69078beee0088e5936c98b
|
||||
content/27: e0fa91c45aa780fc03e91df77417f893
|
||||
content/27: 57662dd91f8d1d807377fd48fa0e9142
|
||||
content/28: b463f54cd5fe2458b5842549fbb5e1ce
|
||||
content/29: 55f8c724e1a2463bc29a32518a512c73
|
||||
content/30: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
@@ -2638,8 +2638,14 @@ checksums:
|
||||
content/139: 33fde4c3da4584b51f06183b7b192a78
|
||||
content/140: bcadfc362b69078beee0088e5936c98b
|
||||
content/141: b7451190f100388d999c183958d787a7
|
||||
content/142: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/143: 4930918f803340baa861bed9cdf789de
|
||||
content/142: d0f9e799e2e5cc62de60668d35fd846f
|
||||
content/143: b19069ff19899fe202217e06e002c447
|
||||
content/144: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/145: 480fd62f8d9cc18467e82f4c3f70beea
|
||||
content/146: bcadfc362b69078beee0088e5936c98b
|
||||
content/147: 4e73a65d3b873f3979587e10a0f39e72
|
||||
content/148: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/149: 4930918f803340baa861bed9cdf789de
|
||||
8f76e389f6226f608571622b015ca6a1:
|
||||
meta/title: ddfe2191ea61b34d8b7cc1d7c19b94ac
|
||||
meta/description: 049ff551f2ebabb15cdea0c71bd8e4eb
|
||||
@@ -4811,9 +4817,9 @@ checksums:
|
||||
content/19: 85547efea8ae0e8170ac4e2030f6be25
|
||||
content/20: 25c56dcdc4af1516c3fbf9d82d96b48d
|
||||
content/21: 56dbe63da14a319cd520ab1615c94be7
|
||||
content/22: e092cde0c92ef09c642a62636e7e3ae3
|
||||
content/22: e039f6c905c8aa148cc3e7af19f05239
|
||||
content/23: c7004f5db8f7134d7e3a36a1916691a2
|
||||
content/24: bbc26961050b132b9bc4f14ba11f407a
|
||||
content/24: 26555018b90fc8fb3ac65cece15f3966
|
||||
content/25: 56dbe63da14a319cd520ab1615c94be7
|
||||
content/26: 3e835ecc38acf2c76179034360d41670
|
||||
content/27: a13bbc3dac7388e1ef4e9cbafdcc8241
|
||||
@@ -49824,35 +49830,39 @@ checksums:
|
||||
content/474: 27c398e669b297cea076e4ce4cc0c5eb
|
||||
9a28da736b42bf8de55126d4c06b6150:
|
||||
meta/title: 418d5c8a18ad73520b38765741601f32
|
||||
meta/description: 2b5a9723c7a45d2be5001d5d056b7c7b
|
||||
meta/description: 41cb31abf94297849fb8a4023cf0211d
|
||||
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
|
||||
content/1: e72670f88454b5b1c955b029de5fa8b5
|
||||
content/2: 821e6394b0a953e2b0842b04ae8f3105
|
||||
content/3: 7fa671d05a60d4f25b4980405c2c7278
|
||||
content/4: 9c8aa3f09c9b2bd50ea4cdff3598ea4e
|
||||
content/5: 263633aee6db9332de806ae50d87de05
|
||||
content/6: 5a7e2171e5f73fec5eae21a50e5de661
|
||||
content/7: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/8: 10d2d4eccb4b8923f048980dc16e43e1
|
||||
content/9: bcadfc362b69078beee0088e5936c98b
|
||||
content/10: d81ef802f80143282cf4e534561a9570
|
||||
content/11: 02233e6212003c1d121424cfd8b86b62
|
||||
content/12: efe2c6dd368708de68a1addbfdb11b0c
|
||||
content/13: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/14: 0f3295854b7de5dbfab1ebd2a130b498
|
||||
content/15: bcadfc362b69078beee0088e5936c98b
|
||||
content/16: 953f353184dc27db1f20156db2a9ad90
|
||||
content/17: 2011e87d0555cd0ab133ef2d35e7a37b
|
||||
content/18: dbf08acb413d845ec419e45b1f986bdb
|
||||
content/19: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/20: 3a8417b390ec7d3d55b1920c721e9006
|
||||
content/21: bcadfc362b69078beee0088e5936c98b
|
||||
content/22: c06a5bb458242baa23d34957034c2fe7
|
||||
content/23: ff043e912417bc29ac7c64520160c07d
|
||||
content/24: 9c2175ab469cb6ff9e62bc8bdcf7621d
|
||||
content/25: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/26: 67e6ba04cf67f92e714ed94e7483dec5
|
||||
content/27: bcadfc362b69078beee0088e5936c98b
|
||||
content/28: fd0f38eb3fe5cf95be366a4ff6b4fb90
|
||||
content/29: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/30: 4a7b2c644e487f3d12b6a6b54f8c6773
|
||||
content/2: d586e5af506d99add847369c0accfb4d
|
||||
content/3: a2ce9ed4954ab55bcebed927cec8e890
|
||||
content/4: 5fc7b723a6adcf201e8deb3f5ed9a9e3
|
||||
content/5: a78981875c359a3343f26ed4d115f899
|
||||
content/6: 821e6394b0a953e2b0842b04ae8f3105
|
||||
content/7: 56a538eaccb1158fb1f7a01cc32f7331
|
||||
content/8: 9c8aa3f09c9b2bd50ea4cdff3598ea4e
|
||||
content/9: 263633aee6db9332de806ae50d87de05
|
||||
content/10: 5a7e2171e5f73fec5eae21a50e5de661
|
||||
content/11: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/12: 5905ef5d0db0354c08394acb0b5cda4b
|
||||
content/13: bcadfc362b69078beee0088e5936c98b
|
||||
content/14: d81ef802f80143282cf4e534561a9570
|
||||
content/15: 02233e6212003c1d121424cfd8b86b62
|
||||
content/16: efe2c6dd368708de68a1addbfdb11b0c
|
||||
content/17: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/18: 2722e8bee100e7bc4590fa02710e9508
|
||||
content/19: bcadfc362b69078beee0088e5936c98b
|
||||
content/20: 953f353184dc27db1f20156db2a9ad90
|
||||
content/21: 2011e87d0555cd0ab133ef2d35e7a37b
|
||||
content/22: dbf08acb413d845ec419e45b1f986bdb
|
||||
content/23: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/24: afc35de2990ed0e9bb8f98dc1b9609ce
|
||||
content/25: bcadfc362b69078beee0088e5936c98b
|
||||
content/26: c06a5bb458242baa23d34957034c2fe7
|
||||
content/27: ff043e912417bc29ac7c64520160c07d
|
||||
content/28: 9c2175ab469cb6ff9e62bc8bdcf7621d
|
||||
content/29: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/30: 20e6bddad8e7f34a3d09e5b0c5678c13
|
||||
content/31: bcadfc362b69078beee0088e5936c98b
|
||||
content/32: fd0f38eb3fe5cf95be366a4ff6b4fb90
|
||||
content/33: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/34: 4a7b2c644e487f3d12b6a6b54f8c6773
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 7322",
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
|
||||
@@ -573,10 +573,10 @@ export default function LoginPage({
|
||||
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
|
||||
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='auth-text-primary font-semibold text-xl tracking-tight'>
|
||||
<DialogTitle className='font-semibold text-black text-xl tracking-tight'>
|
||||
Reset Password
|
||||
</DialogTitle>
|
||||
<DialogDescription className='auth-text-secondary text-sm'>
|
||||
<DialogDescription className='text-muted-foreground text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</DialogDescription>
|
||||
|
||||
@@ -70,6 +70,7 @@ export const FOOTER_TOOLS = [
|
||||
'Salesforce',
|
||||
'SendGrid',
|
||||
'Serper',
|
||||
'ServiceNow',
|
||||
'SharePoint',
|
||||
'Slack',
|
||||
'Smtp',
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
<Link
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replace(' ', '-')}`}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Suspense } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Background, Footer, Nav, StructuredData } from '@/app/(landing)/components'
|
||||
|
||||
// Lazy load heavy components for better initial load performance
|
||||
const Hero = dynamic(() => import('@/app/(landing)/components/hero/hero'), {
|
||||
loading: () => <div className='h-[600px] animate-pulse bg-gray-50' />,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { PostGrid } from '@/app/(landing)/studio/post-grid'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
@@ -18,7 +17,6 @@ export default async function StudioIndex({
|
||||
const all = await getAllPostMeta()
|
||||
const filtered = tag ? all.filter((p) => p.tags.includes(tag)) : all
|
||||
|
||||
// Sort to ensure featured post is first on page 1
|
||||
const sorted =
|
||||
pageNum === 1
|
||||
? filtered.sort((a, b) => {
|
||||
@@ -63,69 +61,7 @@ export default async function StudioIndex({
|
||||
</div> */}
|
||||
|
||||
{/* Grid layout for consistent rows */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, i) => {
|
||||
return (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
width={800}
|
||||
height={450}
|
||||
className='h-48 w-full object-cover'
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-gray-600 text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='-space-x-1.5 flex'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 3)
|
||||
.map((author, idx) => (
|
||||
<Avatar key={idx} className='size-4 border border-white'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-gray-600 text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
.join(', ')}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
|
||||
<>
|
||||
{' '}
|
||||
and{' '}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
|
||||
other
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 >
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PostGrid posts={posts} />
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='mt-10 flex items-center justify-center gap-3'>
|
||||
|
||||
90
apps/sim/app/(landing)/studio/post-grid.tsx
Normal file
90
apps/sim/app/(landing)/studio/post-grid.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
interface Author {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface Post {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
date: string
|
||||
ogImage: string
|
||||
author: Author
|
||||
authors?: Author[]
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, index) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
|
||||
{/* Image container with fixed aspect ratio to prevent layout shift */}
|
||||
<div className='relative aspect-video w-full overflow-hidden'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-gray-600 text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='-space-x-1.5 flex'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 3)
|
||||
.map((author, idx) => (
|
||||
<Avatar key={idx} className='size-4 border border-white'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-gray-600 text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
.join(', ')}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
|
||||
<>
|
||||
{' '}
|
||||
and {(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
|
||||
other
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 > 1
|
||||
? 's'
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname === '/' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/reset-password') ||
|
||||
pathname.startsWith('/sso') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
|
||||
@@ -759,3 +759,24 @@ input[type="search"]::-ms-clear {
|
||||
--surface-elevated: #202020;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove backticks from inline code in prose (Tailwind Typography default)
|
||||
*/
|
||||
.prose code::before,
|
||||
.prose code::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove underlines from heading anchor links in prose
|
||||
*/
|
||||
.prose h1 a,
|
||||
.prose h2 a,
|
||||
.prose h3 a,
|
||||
.prose h4 a,
|
||||
.prose h5 a,
|
||||
.prose h6 a {
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,17 @@ export async function GET(request: NextRequest) {
|
||||
.from(account)
|
||||
.where(and(...whereConditions))
|
||||
|
||||
return NextResponse.json({ accounts })
|
||||
// Use the user's email as the display name (consistent with credential selector)
|
||||
const userEmail = session.user.email
|
||||
|
||||
const accountsWithDisplayName = accounts.map((acc) => ({
|
||||
id: acc.id,
|
||||
accountId: acc.accountId,
|
||||
providerId: acc.providerId,
|
||||
displayName: userEmail || acc.providerId,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ accounts: accountsWithDisplayName })
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch accounts', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn(() => 'https://app.example.com'),
|
||||
}))
|
||||
|
||||
describe('Forget Password API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -15,7 +19,7 @@ describe('Forget Password API Route', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should send password reset email successfully', async () => {
|
||||
it('should send password reset email successfully with same-origin redirectTo', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
forgetPassword: { success: true },
|
||||
@@ -24,7 +28,7 @@ describe('Forget Password API Route', () => {
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://example.com/reset',
|
||||
redirectTo: 'https://app.example.com/reset',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/auth/forget-password/route')
|
||||
@@ -39,12 +43,36 @@ describe('Forget Password API Route', () => {
|
||||
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://example.com/reset',
|
||||
redirectTo: 'https://app.example.com/reset',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject external redirectTo URL', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
forgetPassword: { success: true },
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://evil.com/phishing',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/auth/forget-password/route')
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
|
||||
|
||||
const auth = await import('@/lib/auth')
|
||||
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should send password reset email without redirectTo', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -13,10 +14,15 @@ const forgetPasswordSchema = z.object({
|
||||
.email('Please provide a valid email address'),
|
||||
redirectTo: z
|
||||
.string()
|
||||
.url('Redirect URL must be a valid URL')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((val) => (val === '' ? undefined : val)),
|
||||
.transform((val) => (val === '' || val === undefined ? undefined : val))
|
||||
.refine(
|
||||
(val) => val === undefined || (z.string().url().safeParse(val).success && isSameOrigin(val)),
|
||||
{
|
||||
message: 'Redirect URL must be a valid same-origin URL',
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('@/lib/logs/console/logger', () => ({
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshOAuthToken } from '@/lib/oauth/oauth'
|
||||
import {
|
||||
getCredential,
|
||||
@@ -49,7 +48,6 @@ import {
|
||||
|
||||
const mockDb = db as any
|
||||
const mockRefreshOAuthToken = refreshOAuthToken as any
|
||||
const mockLogger = (createLogger as any)()
|
||||
|
||||
describe('OAuth Utils', () => {
|
||||
beforeEach(() => {
|
||||
@@ -87,7 +85,6 @@ describe('OAuth Utils', () => {
|
||||
const userId = await getUserId('request-id')
|
||||
|
||||
expect(userId).toBeUndefined()
|
||||
expect(mockLogger.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return undefined if workflow is not found', async () => {
|
||||
@@ -96,7 +93,6 @@ describe('OAuth Utils', () => {
|
||||
const userId = await getUserId('request-id', 'nonexistent-workflow-id')
|
||||
|
||||
expect(userId).toBeUndefined()
|
||||
expect(mockLogger.warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,7 +117,6 @@ describe('OAuth Utils', () => {
|
||||
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
|
||||
|
||||
expect(credential).toBeUndefined()
|
||||
expect(mockLogger.warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,7 +134,6 @@ describe('OAuth Utils', () => {
|
||||
|
||||
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ accessToken: 'valid-token', refreshed: false })
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Access token is valid'))
|
||||
})
|
||||
|
||||
it('should refresh token when expired', async () => {
|
||||
@@ -159,13 +153,10 @@ describe('OAuth Utils', () => {
|
||||
|
||||
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
|
||||
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
expect(mockDb.set).toHaveBeenCalled()
|
||||
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Successfully refreshed')
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle refresh token error', async () => {
|
||||
@@ -182,8 +173,6 @@ describe('OAuth Utils', () => {
|
||||
await expect(
|
||||
refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
|
||||
).rejects.toThrow('Failed to refresh token')
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not attempt refresh if no refresh token', async () => {
|
||||
@@ -239,7 +228,7 @@ describe('OAuth Utils', () => {
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
expect(mockDb.set).toHaveBeenCalled()
|
||||
expect(token).toBe('new-token')
|
||||
@@ -251,7 +240,6 @@ describe('OAuth Utils', () => {
|
||||
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(mockLogger.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return null if refresh fails', async () => {
|
||||
@@ -270,7 +258,6 @@ describe('OAuth Utils', () => {
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(mockLogger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,14 +132,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
|
||||
try {
|
||||
// Use the existing refreshOAuthToken function
|
||||
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
|
||||
const instanceUrl =
|
||||
providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
|
||||
const refreshResult = await refreshOAuthToken(
|
||||
providerId,
|
||||
credential.refreshToken!,
|
||||
instanceUrl
|
||||
)
|
||||
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, {
|
||||
@@ -222,13 +215,9 @@ export async function refreshAccessTokenIfNeeded(
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
try {
|
||||
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
|
||||
const instanceUrl =
|
||||
credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
credential.refreshToken!,
|
||||
instanceUrl
|
||||
credential.refreshToken!
|
||||
)
|
||||
|
||||
if (!refreshedToken) {
|
||||
@@ -300,14 +289,7 @@ export async function refreshTokenIfNeeded(
|
||||
}
|
||||
|
||||
try {
|
||||
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
|
||||
const instanceUrl =
|
||||
credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
|
||||
const refreshResult = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
credential.refreshToken!,
|
||||
instanceUrl
|
||||
)
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ServiceNowCallback')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
// Handle OAuth errors from ServiceNow
|
||||
if (error) {
|
||||
logger.error('ServiceNow OAuth error:', { error, errorDescription })
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/workspace?error=servicenow_auth_error&message=${encodeURIComponent(errorDescription || error)}`
|
||||
)
|
||||
}
|
||||
|
||||
const storedState = request.cookies.get('servicenow_oauth_state')?.value
|
||||
const storedInstanceUrl = request.cookies.get('servicenow_instance_url')?.value
|
||||
|
||||
const clientId = env.SERVICENOW_CLIENT_ID
|
||||
const clientSecret = env.SERVICENOW_CLIENT_SECRET
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('ServiceNow credentials not configured')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_config_error`)
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!state || state !== storedState) {
|
||||
logger.error('State mismatch in ServiceNow OAuth callback')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_state_mismatch`)
|
||||
}
|
||||
|
||||
// Validate authorization code
|
||||
if (!code) {
|
||||
logger.error('No code received from ServiceNow')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_code`)
|
||||
}
|
||||
|
||||
// Validate instance URL
|
||||
if (!storedInstanceUrl) {
|
||||
logger.error('No instance URL stored')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_instance`)
|
||||
}
|
||||
|
||||
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow`
|
||||
|
||||
// Exchange authorization code for access token
|
||||
const tokenResponse = await fetch(`${storedInstanceUrl}/oauth_token.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
logger.error('Failed to exchange code for token:', {
|
||||
status: tokenResponse.status,
|
||||
body: errorText,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_token_error`)
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.access_token
|
||||
const refreshToken = tokenData.refresh_token
|
||||
const expiresIn = tokenData.expires_in
|
||||
// ServiceNow always grants 'useraccount' scope but returns empty string
|
||||
const scope = tokenData.scope || 'useraccount'
|
||||
|
||||
logger.info('ServiceNow token exchange successful:', {
|
||||
hasAccessToken: !!accessToken,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
expiresIn,
|
||||
})
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token in response')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_token`)
|
||||
}
|
||||
|
||||
// Redirect to store endpoint with token data in cookies
|
||||
const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/servicenow/store`)
|
||||
|
||||
const response = NextResponse.redirect(storeUrl)
|
||||
|
||||
// Store token data in secure cookies for the store endpoint
|
||||
response.cookies.set('servicenow_pending_token', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60, // 1 minute
|
||||
path: '/',
|
||||
})
|
||||
|
||||
if (refreshToken) {
|
||||
response.cookies.set('servicenow_pending_refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
response.cookies.set('servicenow_pending_instance', storedInstanceUrl, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('servicenow_pending_scope', scope || '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
if (expiresIn) {
|
||||
response.cookies.set('servicenow_pending_expires_in', expiresIn.toString(), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up OAuth state cookies
|
||||
response.cookies.delete('servicenow_oauth_state')
|
||||
response.cookies.delete('servicenow_instance_url')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error in ServiceNow OAuth callback:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_callback_error`)
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('ServiceNowStore')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized attempt to store ServiceNow token')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
|
||||
}
|
||||
|
||||
// Retrieve token data from cookies
|
||||
const accessToken = request.cookies.get('servicenow_pending_token')?.value
|
||||
const refreshToken = request.cookies.get('servicenow_pending_refresh_token')?.value
|
||||
const instanceUrl = request.cookies.get('servicenow_pending_instance')?.value
|
||||
const scope = request.cookies.get('servicenow_pending_scope')?.value
|
||||
const expiresInStr = request.cookies.get('servicenow_pending_expires_in')?.value
|
||||
|
||||
if (!accessToken || !instanceUrl) {
|
||||
logger.error('Missing token or instance URL in cookies')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_missing_data`)
|
||||
}
|
||||
|
||||
// Validate the token by fetching user info from ServiceNow
|
||||
const userResponse = await fetch(
|
||||
`${instanceUrl}/api/now/table/sys_user?sysparm_query=user_name=${encodeURIComponent('javascript:gs.getUserName()')}&sysparm_limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Alternative: Use the instance info endpoint instead
|
||||
let accountIdentifier = instanceUrl
|
||||
let userInfo: Record<string, unknown> | null = null
|
||||
|
||||
// Try to get current user info
|
||||
try {
|
||||
const whoamiResponse = await fetch(`${instanceUrl}/api/now/ui/user/current_user`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (whoamiResponse.ok) {
|
||||
const whoamiData = await whoamiResponse.json()
|
||||
userInfo = whoamiData.result
|
||||
if (userInfo?.user_sys_id) {
|
||||
accountIdentifier = userInfo.user_sys_id as string
|
||||
} else if (userInfo?.user_name) {
|
||||
accountIdentifier = userInfo.user_name as string
|
||||
}
|
||||
logger.info('Retrieved ServiceNow user info', { accountIdentifier })
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not retrieve ServiceNow user info, using instance URL as identifier')
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
const now = new Date()
|
||||
const expiresIn = expiresInStr ? Number.parseInt(expiresInStr, 10) : 3600 // Default to 1 hour
|
||||
const accessTokenExpiresAt = new Date(now.getTime() + expiresIn * 1000)
|
||||
|
||||
// Check for existing ServiceNow account for this user
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'servicenow')),
|
||||
})
|
||||
|
||||
// ServiceNow always grants 'useraccount' scope but returns empty string
|
||||
const effectiveScope = scope?.trim() ? scope : 'useraccount'
|
||||
|
||||
const accountData = {
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken || null,
|
||||
accountId: accountIdentifier,
|
||||
scope: effectiveScope,
|
||||
updatedAt: now,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt,
|
||||
idToken: instanceUrl, // Store instance URL in idToken for API calls
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await db.update(account).set(accountData).where(eq(account.id, existing.id))
|
||||
logger.info('Updated existing ServiceNow account', { accountId: existing.id })
|
||||
} else {
|
||||
await safeAccountInsert(
|
||||
{
|
||||
id: `servicenow_${session.user.id}_${Date.now()}`,
|
||||
userId: session.user.id,
|
||||
providerId: 'servicenow',
|
||||
accountId: accountData.accountId,
|
||||
accessToken: accountData.accessToken,
|
||||
refreshToken: accountData.refreshToken || undefined,
|
||||
accessTokenExpiresAt: accountData.accessTokenExpiresAt,
|
||||
scope: accountData.scope,
|
||||
idToken: accountData.idToken,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{ provider: 'ServiceNow', identifier: instanceUrl }
|
||||
)
|
||||
logger.info('Created new ServiceNow account')
|
||||
}
|
||||
|
||||
// Get return URL from cookie
|
||||
const returnUrl = request.cookies.get('servicenow_return_url')?.value
|
||||
|
||||
const redirectUrl = returnUrl || `${baseUrl}/workspace`
|
||||
const finalUrl = new URL(redirectUrl)
|
||||
finalUrl.searchParams.set('servicenow_connected', 'true')
|
||||
|
||||
const response = NextResponse.redirect(finalUrl.toString())
|
||||
|
||||
// Clean up all ServiceNow cookies
|
||||
response.cookies.delete('servicenow_pending_token')
|
||||
response.cookies.delete('servicenow_pending_refresh_token')
|
||||
response.cookies.delete('servicenow_pending_instance')
|
||||
response.cookies.delete('servicenow_pending_scope')
|
||||
response.cookies.delete('servicenow_pending_expires_in')
|
||||
response.cookies.delete('servicenow_return_url')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error storing ServiceNow token:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_store_error`)
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ServiceNowAuthorize')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* ServiceNow OAuth scopes
|
||||
* useraccount - Default scope for user account access
|
||||
* Note: ServiceNow always returns 'useraccount' in OAuth responses regardless of requested scopes.
|
||||
* Table API permissions are configured at the OAuth application level in ServiceNow.
|
||||
*/
|
||||
const SERVICENOW_SCOPES = 'useraccount'
|
||||
|
||||
/**
|
||||
* Validates a ServiceNow instance URL format
|
||||
*/
|
||||
function isValidInstanceUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (
|
||||
parsed.protocol === 'https:' &&
|
||||
(parsed.hostname.endsWith('.service-now.com') || parsed.hostname.endsWith('.servicenow.com'))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const clientId = env.SERVICENOW_CLIENT_ID
|
||||
|
||||
if (!clientId) {
|
||||
logger.error('SERVICENOW_CLIENT_ID not configured')
|
||||
return NextResponse.json({ error: 'ServiceNow client ID not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const instanceUrl = request.nextUrl.searchParams.get('instanceUrl')
|
||||
const returnUrl = request.nextUrl.searchParams.get('returnUrl')
|
||||
|
||||
if (!instanceUrl) {
|
||||
const returnUrlParam = returnUrl ? encodeURIComponent(returnUrl) : ''
|
||||
return new NextResponse(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Connect ServiceNow Instance</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #81B5A1 0%, #5A8A75 100%);
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
}
|
||||
h2 {
|
||||
color: #111827;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #81B5A1;
|
||||
box-shadow: 0 0 0 3px rgba(129, 181, 161, 0.2);
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #81B5A1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
button:hover {
|
||||
background: #6A9A87;
|
||||
}
|
||||
.help {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Connect Your ServiceNow Instance</h2>
|
||||
<p>Enter your ServiceNow instance URL to continue</p>
|
||||
<div id="error" class="error"></div>
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<input
|
||||
type="text"
|
||||
id="instanceUrl"
|
||||
placeholder="https://mycompany.service-now.com"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Connect Instance</button>
|
||||
</form>
|
||||
<p class="help">Your instance URL looks like: https://yourcompany.service-now.com</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const returnUrl = '${returnUrlParam}';
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const errorEl = document.getElementById('error');
|
||||
let instanceUrl = document.getElementById('instanceUrl').value.trim();
|
||||
|
||||
// Ensure https:// prefix
|
||||
if (!instanceUrl.startsWith('https://') && !instanceUrl.startsWith('http://')) {
|
||||
instanceUrl = 'https://' + instanceUrl;
|
||||
}
|
||||
|
||||
// Validate the URL format
|
||||
try {
|
||||
const parsed = new URL(instanceUrl);
|
||||
if (!parsed.hostname.endsWith('.service-now.com') && !parsed.hostname.endsWith('.servicenow.com')) {
|
||||
errorEl.textContent = 'Please enter a valid ServiceNow instance URL (e.g., https://yourcompany.service-now.com)';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
// Clean the URL (remove trailing slashes, paths)
|
||||
instanceUrl = parsed.origin;
|
||||
} catch {
|
||||
errorEl.textContent = 'Please enter a valid URL';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
let url = window.location.pathname + '?instanceUrl=' + encodeURIComponent(instanceUrl);
|
||||
if (returnUrl) {
|
||||
url += '&returnUrl=' + returnUrl;
|
||||
}
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Validate instance URL
|
||||
if (!isValidInstanceUrl(instanceUrl)) {
|
||||
logger.error('Invalid ServiceNow instance URL:', { instanceUrl })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Invalid ServiceNow instance URL. Must be a valid .service-now.com or .servicenow.com domain.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Clean the instance URL
|
||||
const parsedUrl = new URL(instanceUrl)
|
||||
const cleanInstanceUrl = parsedUrl.origin
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow`
|
||||
|
||||
const state = crypto.randomUUID()
|
||||
|
||||
// ServiceNow OAuth authorization URL
|
||||
const oauthUrl =
|
||||
`${cleanInstanceUrl}/oauth_auth.do?` +
|
||||
new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
scope: SERVICENOW_SCOPES,
|
||||
}).toString()
|
||||
|
||||
logger.info('Initiating ServiceNow OAuth:', {
|
||||
instanceUrl: cleanInstanceUrl,
|
||||
requestedScopes: SERVICENOW_SCOPES,
|
||||
redirectUri,
|
||||
returnUrl: returnUrl || 'not specified',
|
||||
})
|
||||
|
||||
const response = NextResponse.redirect(oauthUrl)
|
||||
|
||||
// Store state and instance URL in cookies for validation in callback
|
||||
response.cookies.set('servicenow_oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('servicenow_instance_url', cleanInstanceUrl, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
if (returnUrl) {
|
||||
response.cookies.set('servicenow_return_url', returnUrl, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error initiating ServiceNow authorization:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflowMcpServer, workspace } from '@sim/db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('McpDiscoverAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - Discover all published MCP servers available to the authenticated user
|
||||
*
|
||||
* This endpoint allows external MCP clients to discover available servers
|
||||
* using just their API key, without needing to know workspace IDs.
|
||||
*
|
||||
* Authentication: API Key (X-API-Key header) or Session
|
||||
*
|
||||
* Returns all published MCP servers from workspaces the user has access to.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Authenticate the request
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header with your Sim API key.',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
// Get all workspaces the user has access to via permissions table
|
||||
const userWorkspacePermissions = await db
|
||||
.select({ entityId: permissions.entityId })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
|
||||
|
||||
const workspaceIds = userWorkspacePermissions.map((w) => w.entityId)
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
servers: [],
|
||||
message: 'No workspaces found for this user',
|
||||
})
|
||||
}
|
||||
|
||||
// Get all published MCP servers from user's workspaces with tool count
|
||||
const servers = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
workspaceName: workspace.name,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
toolCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
|
||||
)`.as('tool_count'),
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.leftJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpServer.isPublished, true),
|
||||
sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`
|
||||
)
|
||||
)
|
||||
.orderBy(workflowMcpServer.name)
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
// Format response with connection URLs
|
||||
const formattedServers = servers.map((server) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
workspace: {
|
||||
id: server.workspaceId,
|
||||
name: server.workspaceName,
|
||||
},
|
||||
toolCount: server.toolCount || 0,
|
||||
publishedAt: server.publishedAt,
|
||||
urls: {
|
||||
http: `${baseUrl}/api/mcp/serve/${server.id}`,
|
||||
sse: `${baseUrl}/api/mcp/serve/${server.id}/sse`,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info(`User ${userId} discovered ${formattedServers.length} MCP servers`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
servers: formattedServers,
|
||||
authentication: {
|
||||
method: 'API Key',
|
||||
header: 'X-API-Key',
|
||||
description: 'Include your Sim API key in the X-API-Key header for all MCP requests',
|
||||
},
|
||||
usage: {
|
||||
listTools: {
|
||||
method: 'POST',
|
||||
body: '{"jsonrpc":"2.0","id":1,"method":"tools/list"}',
|
||||
},
|
||||
callTool: {
|
||||
method: 'POST',
|
||||
body: '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"TOOL_NAME","arguments":{}}}',
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error discovering MCP servers:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to discover MCP servers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServeAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP JSON-RPC Request
|
||||
*/
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP JSON-RPC Response
|
||||
*/
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
result?: unknown
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON-RPC success response
|
||||
*/
|
||||
function createJsonRpcResponse(id: string | number, result: unknown): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON-RPC error response
|
||||
*/
|
||||
function createJsonRpcError(
|
||||
id: string | number,
|
||||
code: number,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message, data },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the server exists and is published
|
||||
*/
|
||||
async function validateServer(serverId: string) {
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Server info and capabilities (MCP initialize)
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Return server capabilities
|
||||
return NextResponse.json({
|
||||
name: server.name,
|
||||
version: '1.0.0',
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
instructions: `This MCP server exposes workflow tools from Sim Studio. Each tool executes a deployed workflow.`,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting MCP server info:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Handle MCP JSON-RPC requests
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse JSON-RPC request
|
||||
const body = await request.json()
|
||||
const rpcRequest = body as JsonRpcRequest
|
||||
|
||||
if (rpcRequest.jsonrpc !== '2.0' || !rpcRequest.method) {
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest?.id || 0, -32600, 'Invalid Request'), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle different MCP methods
|
||||
switch (rpcRequest.method) {
|
||||
case 'initialize':
|
||||
return NextResponse.json(
|
||||
createJsonRpcResponse(rpcRequest.id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: server.name,
|
||||
version: '1.0.0',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
case 'tools/list':
|
||||
return handleToolsList(rpcRequest, serverId)
|
||||
|
||||
case 'tools/call': {
|
||||
// Get the API key from the request to forward to the workflow execute call
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
return handleToolsCall(rpcRequest, serverId, auth.userId, server.workspaceId, apiKey)
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
return NextResponse.json(createJsonRpcResponse(rpcRequest.id, {}))
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32601, `Method not found: ${rpcRequest.method}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP request:', error)
|
||||
return NextResponse.json(createJsonRpcError(0, -32603, 'Internal error'), { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/list method
|
||||
*/
|
||||
async function handleToolsList(
|
||||
rpcRequest: JsonRpcRequest,
|
||||
serverId: string
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
const mcpTools = tools
|
||||
.filter((tool) => tool.isEnabled)
|
||||
.map((tool) => ({
|
||||
name: tool.toolName,
|
||||
description: tool.toolDescription || `Execute workflow tool: ${tool.toolName}`,
|
||||
inputSchema: tool.parameterSchema || {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: {
|
||||
type: 'object',
|
||||
description: 'Input data for the workflow',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return NextResponse.json(createJsonRpcResponse(rpcRequest.id, { tools: mcpTools }))
|
||||
} catch (error) {
|
||||
logger.error('Error listing tools:', error)
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest.id, -32603, 'Failed to list tools'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/call method
|
||||
*/
|
||||
async function handleToolsCall(
|
||||
rpcRequest: JsonRpcRequest,
|
||||
serverId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
apiKey?: string | null
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const params = rpcRequest.params as
|
||||
| { name: string; arguments?: Record<string, unknown> }
|
||||
| undefined
|
||||
|
||||
if (!params?.name) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, 'Invalid params: tool name required'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find the tool
|
||||
const [tool] = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
.then((tools) => tools.filter((t) => t.toolName === params.name))
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, `Tool not found: ${params.name}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!tool.isEnabled) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, `Tool is disabled: ${params.name}`),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow is still deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id, isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, tool.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord || !workflowRecord.isDeployed) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32603, 'Workflow is not deployed'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
const baseUrl = getBaseUrl()
|
||||
const executeUrl = `${baseUrl}/api/workflows/${tool.workflowId}/execute`
|
||||
|
||||
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
|
||||
|
||||
// Build headers for the internal execute call
|
||||
const executeHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Forward the API key for authentication
|
||||
if (apiKey) {
|
||||
executeHeaders['X-API-Key'] = apiKey
|
||||
}
|
||||
|
||||
const executeResponse = await fetch(executeUrl, {
|
||||
method: 'POST',
|
||||
headers: executeHeaders,
|
||||
body: JSON.stringify({
|
||||
input: params.arguments || {},
|
||||
triggerType: 'mcp',
|
||||
}),
|
||||
})
|
||||
|
||||
const executeResult = await executeResponse.json()
|
||||
|
||||
if (!executeResponse.ok) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(
|
||||
rpcRequest.id,
|
||||
-32603,
|
||||
executeResult.error || 'Workflow execution failed'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Format response for MCP
|
||||
const content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(executeResult.output || executeResult, null, 2),
|
||||
},
|
||||
]
|
||||
|
||||
return NextResponse.json(
|
||||
createJsonRpcResponse(rpcRequest.id, {
|
||||
content,
|
||||
isError: !executeResult.success,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error calling tool:', error)
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest.id, -32603, 'Tool execution failed'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* MCP SSE/HTTP Endpoint
|
||||
*
|
||||
* Implements MCP protocol using the official @modelcontextprotocol/sdk
|
||||
* with a Next.js-compatible transport adapter.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMcpSseStream, handleMcpRequest } from '@/lib/mcp/workflow-mcp-server'
|
||||
|
||||
const logger = createLogger('WorkflowMcpSSE')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
interface RouteParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the server exists and is published
|
||||
*/
|
||||
async function validateServer(serverId: string) {
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - SSE endpoint for MCP protocol
|
||||
* Establishes a Server-Sent Events connection for MCP notifications
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server exists and is published
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
// Create SSE stream using the SDK-based server
|
||||
const stream = createMcpSseStream({
|
||||
serverId,
|
||||
serverName: server.name,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
apiKey,
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
...SSE_HEADERS,
|
||||
'X-MCP-Server-Id': serverId,
|
||||
'X-MCP-Server-Name': server.name,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error establishing SSE connection:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Handle MCP JSON-RPC messages
|
||||
* This is the primary endpoint for MCP protocol messages using the SDK
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Server not found' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Server is not published' },
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Unauthorized' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
// Handle the request using the SDK-based server
|
||||
return handleMcpRequest(
|
||||
{
|
||||
serverId,
|
||||
serverName: server.name,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
apiKey,
|
||||
},
|
||||
request
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP POST request:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32603, message: 'Internal error' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Handle session termination
|
||||
* MCP clients may send DELETE to end a session
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server exists
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`MCP session terminated for server ${serverId}`)
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP DELETE request:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerPublishAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Publish a workflow MCP server (make it accessible via OAuth)
|
||||
*/
|
||||
export const POST = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Publishing workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id, isPublished: workflowMcpServer.isPublished })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
if (existingServer.isPublished) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Server is already published'),
|
||||
'Server is already published',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Check if server has at least one tool
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
.limit(1)
|
||||
|
||||
if (tools.length === 0) {
|
||||
return createMcpErrorResponse(
|
||||
new Error(
|
||||
'Cannot publish server without any tools. Add at least one workflow as a tool first.'
|
||||
),
|
||||
'Server has no tools',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set({
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}/sse`
|
||||
|
||||
logger.info(`[${requestId}] Successfully published workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({
|
||||
server: updatedServer,
|
||||
mcpServerUrl,
|
||||
message: 'Server published successfully. External MCP clients can now connect using OAuth.',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error publishing workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to publish workflow MCP server'),
|
||||
'Failed to publish workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Unpublish a workflow MCP server
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Unpublishing workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id, isPublished: workflowMcpServer.isPublished })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
if (!existingServer.isPublished) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Server is not published'),
|
||||
'Server is not published',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set({
|
||||
isPublished: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully unpublished workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({
|
||||
server: updatedServer,
|
||||
message: 'Server unpublished successfully. External MCP clients can no longer connect.',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error unpublishing workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to unpublish workflow MCP server'),
|
||||
'Failed to unpublish workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,157 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Get a specific workflow MCP server with its tools
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`)
|
||||
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const tools = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ server, tools })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to get workflow MCP server'),
|
||||
'Failed to get workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH - Update a workflow MCP server
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name.trim()
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updateData.description = body.description?.trim() || null
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set(updateData)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ server: updatedServer })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to update workflow MCP server'),
|
||||
'Failed to update workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Delete a workflow MCP server and all its tools
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`)
|
||||
|
||||
const [deletedServer] = await db
|
||||
.delete(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deletedServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to delete workflow MCP server'),
|
||||
'Failed to delete workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,178 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpToolAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
toolId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Get a specific tool
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [tool] = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.limit(1)
|
||||
|
||||
if (!tool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
return createMcpSuccessResponse({ tool })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to get tool'),
|
||||
'Failed to get tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH - Update a tool's configuration
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingTool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (body.toolName !== undefined) {
|
||||
updateData.toolName = body.toolName.trim()
|
||||
}
|
||||
if (body.toolDescription !== undefined) {
|
||||
updateData.toolDescription = body.toolDescription?.trim() || null
|
||||
}
|
||||
if (body.parameterSchema !== undefined) {
|
||||
updateData.parameterSchema = body.parameterSchema
|
||||
}
|
||||
if (body.isEnabled !== undefined) {
|
||||
updateData.isEnabled = body.isEnabled
|
||||
}
|
||||
|
||||
const [updatedTool] = await db
|
||||
.update(workflowMcpTool)
|
||||
.set(updateData)
|
||||
.where(eq(workflowMcpTool.id, toolId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated tool ${toolId}`)
|
||||
|
||||
return createMcpSuccessResponse({ tool: updatedTool })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to update tool'),
|
||||
'Failed to update tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Remove a tool from an MCP server
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [deletedTool] = await db
|
||||
.delete(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.returning()
|
||||
|
||||
if (!deletedTool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted tool ${toolId}`)
|
||||
|
||||
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to delete tool'),
|
||||
'Failed to delete tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,226 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpToolsAPI')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - List all tools for a workflow MCP server
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Get tools with workflow details
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
serverId: workflowMcpTool.serverId,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
createdAt: workflowMcpTool.createdAt,
|
||||
updatedAt: workflowMcpTool.updatedAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.leftJoin(workflow, eq(workflowMcpTool.workflowId, workflow.id))
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ tools })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing tools:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to list tools'),
|
||||
'Failed to list tools',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Add a workflow as a tool to an MCP server
|
||||
*/
|
||||
export const POST = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, {
|
||||
workflowId: body.workflowId,
|
||||
})
|
||||
|
||||
if (!body.workflowId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Missing required field: workflowId'),
|
||||
'Missing required field',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow exists and is deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
workspaceId: workflow.workspaceId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, body.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow belongs to the same workspace
|
||||
if (workflowRecord.workspaceId !== workspaceId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow does not belong to this workspace'),
|
||||
'Access denied',
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
if (!workflowRecord.isDeployed) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow must be deployed before adding as a tool'),
|
||||
'Workflow not deployed',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow has a valid start block
|
||||
const hasStartBlock = await hasValidStartBlock(body.workflowId)
|
||||
if (!hasStartBlock) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow must have a Start block to be used as an MCP tool'),
|
||||
'No start block found',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Check if tool already exists for this workflow
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.serverId, serverId),
|
||||
eq(workflowMcpTool.workflowId, body.workflowId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingTool) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('This workflow is already added as a tool to this server'),
|
||||
'Tool already exists',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
// Generate tool name and description
|
||||
const toolName = body.toolName?.trim() || sanitizeToolName(workflowRecord.name)
|
||||
const toolDescription =
|
||||
body.toolDescription?.trim() ||
|
||||
workflowRecord.description ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
|
||||
// Create the tool
|
||||
const toolId = crypto.randomUUID()
|
||||
const [tool] = await db
|
||||
.insert(workflowMcpTool)
|
||||
.values({
|
||||
id: toolId,
|
||||
serverId,
|
||||
workflowId: body.workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: body.parameterSchema || {},
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ tool }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error adding tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to add tool'),
|
||||
'Failed to add tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServersAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - List all workflow MCP servers for the workspace
|
||||
*/
|
||||
export const GET = withMcpAuth('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`)
|
||||
|
||||
const servers = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
toolCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
|
||||
)`.as('tool_count'),
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`
|
||||
)
|
||||
return createMcpSuccessResponse({ servers })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing workflow MCP servers:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to list workflow MCP servers'),
|
||||
'Failed to list workflow MCP servers',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Create a new workflow MCP server
|
||||
*/
|
||||
export const POST = withMcpAuth('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow MCP server:`, {
|
||||
name: body.name,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
if (!body.name) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Missing required field: name'),
|
||||
'Missing required field',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const serverId = crypto.randomUUID()
|
||||
|
||||
const [server] = await db
|
||||
.insert(workflowMcpServer)
|
||||
.values({
|
||||
id: serverId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
name: body.name.trim(),
|
||||
description: body.description?.trim() || null,
|
||||
isPublished: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ server }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to create workflow MCP server'),
|
||||
'Failed to create workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -20,6 +20,12 @@ export async function POST(request: Request) {
|
||||
cloudId: providedCloudId,
|
||||
issueType,
|
||||
parent,
|
||||
labels,
|
||||
duedate,
|
||||
reporter,
|
||||
environment,
|
||||
customFieldId,
|
||||
customFieldValue,
|
||||
} = await request.json()
|
||||
|
||||
if (!domain) {
|
||||
@@ -94,17 +100,57 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (priority !== undefined && priority !== null && priority !== '') {
|
||||
fields.priority = {
|
||||
name: priority,
|
||||
const isNumericId = /^\d+$/.test(priority)
|
||||
fields.priority = isNumericId ? { id: priority } : { name: priority }
|
||||
}
|
||||
|
||||
if (labels !== undefined && labels !== null && Array.isArray(labels) && labels.length > 0) {
|
||||
fields.labels = labels
|
||||
}
|
||||
|
||||
if (duedate !== undefined && duedate !== null && duedate !== '') {
|
||||
fields.duedate = duedate
|
||||
}
|
||||
|
||||
if (reporter !== undefined && reporter !== null && reporter !== '') {
|
||||
fields.reporter = {
|
||||
id: reporter,
|
||||
}
|
||||
}
|
||||
|
||||
if (assignee !== undefined && assignee !== null && assignee !== '') {
|
||||
fields.assignee = {
|
||||
id: assignee,
|
||||
if (environment !== undefined && environment !== null && environment !== '') {
|
||||
fields.environment = {
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: environment,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
customFieldId !== undefined &&
|
||||
customFieldId !== null &&
|
||||
customFieldId !== '' &&
|
||||
customFieldValue !== undefined &&
|
||||
customFieldValue !== null &&
|
||||
customFieldValue !== ''
|
||||
) {
|
||||
const fieldId = customFieldId.startsWith('customfield_')
|
||||
? customFieldId
|
||||
: `customfield_${customFieldId}`
|
||||
|
||||
fields[fieldId] = customFieldValue
|
||||
}
|
||||
|
||||
const body = { fields }
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -132,16 +178,47 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
logger.info('Successfully created Jira issue:', responseData.key)
|
||||
const issueKey = responseData.key || 'unknown'
|
||||
logger.info('Successfully created Jira issue:', issueKey)
|
||||
|
||||
let assigneeId: string | undefined
|
||||
if (assignee !== undefined && assignee !== null && assignee !== '') {
|
||||
const assignUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueKey}/assignee`
|
||||
logger.info('Assigning issue to:', assignee)
|
||||
|
||||
const assignResponse = await fetch(assignUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accountId: assignee,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!assignResponse.ok) {
|
||||
const assignErrorText = await assignResponse.text()
|
||||
logger.warn('Failed to assign issue (issue was created successfully):', {
|
||||
status: assignResponse.status,
|
||||
error: assignErrorText,
|
||||
})
|
||||
} else {
|
||||
assigneeId = assignee
|
||||
logger.info('Successfully assigned issue to:', assignee)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: responseData.key || 'unknown',
|
||||
issueKey: issueKey,
|
||||
summary: responseData.fields?.summary || 'Issue created',
|
||||
success: true,
|
||||
url: `https://${domain}/browse/${responseData.key}`,
|
||||
url: `https://${domain}/browse/${issueKey}`,
|
||||
...(assigneeId && { assigneeId }),
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,121 +1,17 @@
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowDeployAPI')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from workflow blocks and generate MCP tool parameter schema
|
||||
*/
|
||||
async function generateMcpToolSchema(workflowId: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalizedData?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(normalizedData.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all MCP tools that reference this workflow with the latest parameter schema.
|
||||
* If the workflow no longer has a start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnDeploy(workflowId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if workflow still has a valid start block
|
||||
const hasStart = await hasValidStartBlock(workflowId)
|
||||
if (!hasStart) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - workflow no longer has a start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the latest parameter schema
|
||||
const parameterSchema = await generateMcpToolSchema(workflowId)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow: ${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all MCP tools that reference this workflow when undeploying
|
||||
*/
|
||||
async function removeMcpToolsOnUndeploy(workflowId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
const result = await db
|
||||
.delete(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(`[${requestId}] Removed MCP tools for undeployed workflow: ${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error removing MCP tools:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -223,9 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
// Sync MCP tools with the latest parameter schema
|
||||
await syncMcpToolsOnDeploy(id, requestId)
|
||||
|
||||
const responseApiKeyInfo = workflowData!.workspaceId
|
||||
? 'Workspace API keys'
|
||||
: 'Personal API keys'
|
||||
@@ -274,9 +167,6 @@ export async function DELETE(
|
||||
.where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Remove all MCP tools that reference this workflow
|
||||
await removeMcpToolsOnUndeploy(id, requestId)
|
||||
|
||||
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
||||
|
||||
// Track workflow undeployment
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -16,80 +11,6 @@ const logger = createLogger('WorkflowActivateDeploymentAPI')
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from a deployment version state and generate MCP tool parameter schema
|
||||
*/
|
||||
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
|
||||
try {
|
||||
if (!state?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(state.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema from state:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync MCP tools when activating a deployment version.
|
||||
* If the version has no start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnVersionActivate(
|
||||
workflowId: string,
|
||||
versionState: any,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the activated version has a valid start block
|
||||
if (!hasValidStartBlockInState(versionState)) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - activated version has no start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the parameter schema from the activated version's state
|
||||
const parameterSchema = generateMcpToolSchemaFromState(versionState)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow version activation: ${workflowId}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools on version activate:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
@@ -110,18 +31,6 @@ export async function POST(
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Get the state of the version being activated for MCP tool sync
|
||||
const [versionData] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
@@ -156,11 +65,6 @@ export async function POST(
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Sync MCP tools with the activated version's parameter schema
|
||||
if (versionData?.state) {
|
||||
await syncMcpToolsOnVersionActivate(id, versionData.state, requestId)
|
||||
}
|
||||
|
||||
return createSuccessResponse({ success: true, deployedAt: now })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -18,80 +13,6 @@ const logger = createLogger('RevertToDeploymentVersionAPI')
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from a deployment version state and generate MCP tool parameter schema
|
||||
*/
|
||||
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
|
||||
try {
|
||||
if (!state?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(state.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema from state:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync MCP tools when reverting to a deployment version.
|
||||
* If the version has no start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnRevert(
|
||||
workflowId: string,
|
||||
versionState: any,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the reverted version has a valid start block
|
||||
if (!hasValidStartBlockInState(versionState)) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - reverted version has no start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the parameter schema from the reverted version's state
|
||||
const parameterSchema = generateMcpToolSchemaFromState(versionState)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow revert: ${workflowId}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools on revert:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
@@ -166,9 +87,6 @@ export async function POST(
|
||||
.set({ lastSynced: new Date(), updatedAt: new Date() })
|
||||
.where(eq(workflow.id, id))
|
||||
|
||||
// Sync MCP tools with the reverted version's parameter schema
|
||||
await syncMcpToolsOnRevert(id, deployedState, requestId)
|
||||
|
||||
try {
|
||||
const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketServerUrl}/api/workflow-reverted`, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { processInputFileFields } from '@/lib/execution/files'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
@@ -30,7 +31,7 @@ const logger = createLogger('WorkflowExecuteAPI')
|
||||
|
||||
const ExecuteWorkflowSchema = z.object({
|
||||
selectedOutputs: z.array(z.string()).optional().default([]),
|
||||
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']).optional(),
|
||||
triggerType: z.enum(ALL_TRIGGER_TYPES).optional(),
|
||||
stream: z.boolean().optional(),
|
||||
useDraftState: z.boolean().optional(),
|
||||
input: z.any().optional(),
|
||||
@@ -227,7 +228,7 @@ type AsyncExecutionParams = {
|
||||
workflowId: string
|
||||
userId: string
|
||||
input: any
|
||||
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,15 +371,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
})
|
||||
|
||||
const executionId = uuidv4()
|
||||
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
let loggingTriggerType: LoggingTriggerType = 'manual'
|
||||
if (
|
||||
triggerType === 'api' ||
|
||||
triggerType === 'chat' ||
|
||||
triggerType === 'webhook' ||
|
||||
triggerType === 'schedule' ||
|
||||
triggerType === 'manual' ||
|
||||
triggerType === 'mcp'
|
||||
triggerType === 'manual'
|
||||
) {
|
||||
loggingTriggerType = triggerType as LoggingTriggerType
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
|
||||
|
||||
const logger = createLogger('WorkspaceNotificationAPI')
|
||||
|
||||
const levelFilterSchema = z.array(z.enum(['info', 'error']))
|
||||
const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']))
|
||||
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
|
||||
|
||||
const alertRuleSchema = z.enum([
|
||||
'consecutive_failures',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
|
||||
|
||||
@@ -14,7 +15,7 @@ const logger = createLogger('WorkspaceNotificationsAPI')
|
||||
|
||||
const notificationTypeSchema = z.enum(['webhook', 'email', 'slack'])
|
||||
const levelFilterSchema = z.array(z.enum(['info', 'error']))
|
||||
const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']))
|
||||
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
|
||||
|
||||
const alertRuleSchema = z.enum([
|
||||
'consecutive_failures',
|
||||
@@ -80,7 +81,7 @@ const createNotificationSchema = z
|
||||
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]),
|
||||
allWorkflows: z.boolean().default(false),
|
||||
levelFilter: levelFilterSchema.default(['info', 'error']),
|
||||
triggerFilter: triggerFilterSchema.default(['api', 'webhook', 'schedule', 'manual', 'chat']),
|
||||
triggerFilter: triggerFilterSchema.default([...ALL_TRIGGER_TYPES]),
|
||||
includeFinalOutput: z.boolean().default(false),
|
||||
includeTraceSpans: z.boolean().default(false),
|
||||
includeRateLimits: z.boolean().default(false),
|
||||
|
||||
@@ -104,6 +104,8 @@ export function SlackChannelSelector({
|
||||
disabled={disabled || channels.length === 0}
|
||||
isLoading={isLoading}
|
||||
error={fetchError}
|
||||
searchable
|
||||
searchPlaceholder='Search channels...'
|
||||
/>
|
||||
{selectedChannel && !fetchError && (
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SlackIcon } from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ALL_TRIGGER_TYPES, type TriggerType } from '@/lib/logs/types'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import {
|
||||
type NotificationSubscription,
|
||||
@@ -43,7 +44,6 @@ const PRIMARY_BUTTON_STYLES =
|
||||
|
||||
type NotificationType = 'webhook' | 'email' | 'slack'
|
||||
type LogLevel = 'info' | 'error'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
type AlertRule =
|
||||
| 'none'
|
||||
| 'consecutive_failures'
|
||||
@@ -84,7 +84,6 @@ interface NotificationSettingsProps {
|
||||
}
|
||||
|
||||
const LOG_LEVELS: LogLevel[] = ['info', 'error']
|
||||
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']
|
||||
|
||||
function formatAlertConfigLabel(config: {
|
||||
rule: AlertRule
|
||||
@@ -137,7 +136,7 @@ export function NotificationSettings({
|
||||
workflowIds: [] as string[],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'] as LogLevel[],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'] as TriggerType[],
|
||||
triggerFilter: [...ALL_TRIGGER_TYPES] as TriggerType[],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
@@ -207,7 +206,7 @@ export function NotificationSettings({
|
||||
workflowIds: [],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'],
|
||||
triggerFilter: [...ALL_TRIGGER_TYPES],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
@@ -768,7 +767,7 @@ export function NotificationSettings({
|
||||
<Combobox
|
||||
options={slackAccounts.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: acc.accountId,
|
||||
label: acc.displayName || 'Slack Workspace',
|
||||
}))}
|
||||
value={formData.slackAccountId}
|
||||
onChange={(value) => {
|
||||
@@ -859,7 +858,7 @@ export function NotificationSettings({
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[var(--text-secondary)]'>Trigger Type Filters</Label>
|
||||
<Combobox
|
||||
options={TRIGGER_TYPES.map((trigger) => ({
|
||||
options={ALL_TRIGGER_TYPES.map((trigger) => ({
|
||||
label: trigger.charAt(0).toUpperCase() + trigger.slice(1),
|
||||
value: trigger,
|
||||
}))}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import { AutocompleteSearch } from './components/search'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
||||
|
||||
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
|
||||
{ value: 'All time', label: 'All time' },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/emcn'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
||||
const RUNNING_COLOR = '#22c55e' as const
|
||||
const PENDING_COLOR = '#f59e0b' as const
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ const ACTION_VERBS = [
|
||||
'Generated',
|
||||
'Rendering',
|
||||
'Rendered',
|
||||
'Sleeping',
|
||||
'Slept',
|
||||
'Resumed',
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -580,6 +583,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
(toolCall.state === (ClientToolCallState.executing as any) ||
|
||||
toolCall.state === ('executing' as any))
|
||||
|
||||
const showWake =
|
||||
toolCall.name === 'sleep' &&
|
||||
(toolCall.state === (ClientToolCallState.executing as any) ||
|
||||
toolCall.state === ('executing' as any))
|
||||
|
||||
const handleStateChange = (state: any) => {
|
||||
forceUpdate({})
|
||||
onStateChange?.(state)
|
||||
@@ -1102,6 +1110,37 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
Move to Background
|
||||
</Button>
|
||||
</div>
|
||||
) : showWake ? (
|
||||
<div className='mt-[8px]'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const instance = getClientTool(toolCall.id)
|
||||
// Get elapsed seconds before waking
|
||||
const elapsedSeconds = instance?.getElapsedSeconds?.() || 0
|
||||
// Transition to background state locally so UI updates immediately
|
||||
// Pass elapsed seconds in the result so dynamic text can use it
|
||||
instance?.setState?.((ClientToolCallState as any).background, {
|
||||
result: { _elapsedSeconds: elapsedSeconds },
|
||||
})
|
||||
// Update the tool call params in the store to include elapsed time for display
|
||||
const { updateToolCallParams } = useCopilotStore.getState()
|
||||
updateToolCallParams?.(toolCall.id, { _elapsedSeconds: Math.round(elapsedSeconds) })
|
||||
await instance?.markToolComplete?.(
|
||||
200,
|
||||
`User woke you up after ${Math.round(elapsedSeconds)} seconds`
|
||||
)
|
||||
// Optionally force a re-render; store should sync state from server
|
||||
forceUpdate({})
|
||||
onStateChange?.('background')
|
||||
} catch {}
|
||||
}}
|
||||
variant='primary'
|
||||
title='Wake'
|
||||
>
|
||||
Wake
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useDeleteWorkflowMcpTool,
|
||||
useUpdateWorkflowMcpTool,
|
||||
useWorkflowMcpServers,
|
||||
useWorkflowMcpTools,
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('McpToolDeploy')
|
||||
|
||||
interface McpToolDeployProps {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
workflowDescription?: string | null
|
||||
isDeployed: boolean
|
||||
onAddedToServer?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract input format from workflow blocks using SubBlockStore
|
||||
* The actual input format values are stored in useSubBlockStore, not directly in the block structure
|
||||
*/
|
||||
function extractInputFormat(
|
||||
blocks: Record<string, unknown>
|
||||
): Array<{ name: string; type: string }> {
|
||||
// Find the starter block
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
|
||||
const blockObj = block as Record<string, unknown>
|
||||
const blockType = blockObj.type
|
||||
|
||||
// Check for all possible start/trigger block types
|
||||
if (
|
||||
blockType === 'starter' ||
|
||||
blockType === 'start' ||
|
||||
blockType === 'start_trigger' || // This is the unified start block type
|
||||
blockType === 'api' ||
|
||||
blockType === 'api_trigger' ||
|
||||
blockType === 'input_trigger'
|
||||
) {
|
||||
// Get the inputFormat value from the SubBlockStore (where the actual values are stored)
|
||||
const inputFormatValue = useSubBlockStore.getState().getValue(blockId, 'inputFormat')
|
||||
|
||||
if (Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
return inputFormatValue
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
}
|
||||
|
||||
// Fallback: try to get from block's subBlocks structure (for backwards compatibility)
|
||||
const subBlocks = blockObj.subBlocks as Record<string, unknown> | undefined
|
||||
if (subBlocks?.inputFormat) {
|
||||
const inputFormatSubBlock = subBlocks.inputFormat as Record<string, unknown>
|
||||
const value = inputFormatSubBlock.value
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON Schema from input format using the shared utility
|
||||
* Optionally applies custom descriptions from the UI
|
||||
*/
|
||||
function generateParameterSchema(
|
||||
inputFormat: Array<{ name: string; type: string }>,
|
||||
customDescriptions?: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
// Convert to InputFormatField with descriptions
|
||||
const fieldsWithDescriptions = inputFormat.map((field) => ({
|
||||
...field,
|
||||
description: customDescriptions?.[field.name]?.trim() || undefined,
|
||||
}))
|
||||
return generateToolInputSchema(fieldsWithDescriptions) as unknown as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter names from a tool's parameter schema
|
||||
*/
|
||||
function getToolParameterNames(schema: Record<string, unknown>): string[] {
|
||||
const properties = schema.properties as Record<string, unknown> | undefined
|
||||
if (!properties) return []
|
||||
return Object.keys(properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tool's parameters differ from the current workflow's input format
|
||||
*/
|
||||
function hasParameterMismatch(
|
||||
tool: WorkflowMcpTool,
|
||||
currentInputFormat: Array<{ name: string; type: string }>
|
||||
): boolean {
|
||||
const toolParams = getToolParameterNames(tool.parameterSchema as Record<string, unknown>)
|
||||
const currentParams = currentInputFormat.map((f) => f.name)
|
||||
|
||||
if (toolParams.length !== currentParams.length) return true
|
||||
|
||||
const toolParamSet = new Set(toolParams)
|
||||
for (const param of currentParams) {
|
||||
if (!toolParamSet.has(param)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to query tools for a single server and report back via callback.
|
||||
* This pattern avoids calling hooks in a loop.
|
||||
*/
|
||||
function ServerToolsQuery({
|
||||
workspaceId,
|
||||
server,
|
||||
workflowId,
|
||||
onData,
|
||||
}: {
|
||||
workspaceId: string
|
||||
server: WorkflowMcpServer
|
||||
workflowId: string
|
||||
onData: (serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => void
|
||||
}) {
|
||||
const { data: tools, isLoading } = useWorkflowMcpTools(workspaceId, server.id)
|
||||
|
||||
useEffect(() => {
|
||||
const tool = tools?.find((t) => t.workflowId === workflowId) || null
|
||||
onData(server.id, tool, isLoading)
|
||||
}, [tools, isLoading, workflowId, server.id, onData])
|
||||
|
||||
return null // This component doesn't render anything
|
||||
}
|
||||
|
||||
interface ToolOnServerProps {
|
||||
server: WorkflowMcpServer
|
||||
tool: WorkflowMcpTool
|
||||
workspaceId: string
|
||||
currentInputFormat: Array<{ name: string; type: string }>
|
||||
currentParameterSchema: Record<string, unknown>
|
||||
workflowDescription: string | null | undefined
|
||||
onRemoved: (serverId: string) => void
|
||||
onUpdated: () => void
|
||||
}
|
||||
|
||||
function ToolOnServer({
|
||||
server,
|
||||
tool,
|
||||
workspaceId,
|
||||
currentInputFormat,
|
||||
currentParameterSchema,
|
||||
workflowDescription,
|
||||
onRemoved,
|
||||
onUpdated,
|
||||
}: ToolOnServerProps) {
|
||||
const deleteToolMutation = useDeleteWorkflowMcpTool()
|
||||
const updateToolMutation = useUpdateWorkflowMcpTool()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
const needsUpdate = hasParameterMismatch(tool, currentInputFormat)
|
||||
const toolParams = getToolParameterNames(tool.parameterSchema as Record<string, unknown>)
|
||||
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: server.id,
|
||||
toolId: tool.id,
|
||||
})
|
||||
onRemoved(server.id)
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: server.id,
|
||||
toolId: tool.id,
|
||||
toolDescription: workflowDescription || `Execute workflow`,
|
||||
parameterSchema: currentParameterSchema,
|
||||
})
|
||||
onUpdated()
|
||||
logger.info(`Updated tool ${tool.id} with new parameters`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfirm) {
|
||||
return (
|
||||
<div className='flex items-center justify-between rounded-[6px] border border-[var(--text-error)]/30 bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
<span className='text-[12px] text-[var(--text-secondary)]'>Remove from {server.name}?</span>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className='h-[24px] px-[8px] text-[11px]'
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleRemove}
|
||||
className='h-[24px] px-[8px] text-[11px] text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
{deleteToolMutation.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-[6px] border bg-[var(--surface-3)]'>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between px-[10px] py-[8px]'
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{showDetails ? (
|
||||
<ChevronDown className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>{server.name}</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
{needsUpdate && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-amber-500/50 bg-amber-500/10 text-[10px] text-amber-500'
|
||||
>
|
||||
<AlertTriangle className='mr-[4px] h-[10px] w-[10px]' />
|
||||
Needs Update
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-[4px]' onClick={(e) => e.stopPropagation()}>
|
||||
{needsUpdate && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleUpdate}
|
||||
disabled={updateToolMutation.isPending}
|
||||
className='h-[24px] px-[8px] text-[11px] text-amber-500 hover:text-amber-600'
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'mr-[4px] h-[10px] w-[10px]',
|
||||
updateToolMutation.isPending && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
{updateToolMutation.isPending ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className='h-[24px] w-[24px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash2 className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className='border-[var(--border)] border-t px-[10px] py-[8px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>Tool Name</span>
|
||||
<span className='font-mono text-[11px] text-[var(--text-secondary)]'>
|
||||
{tool.toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start justify-between gap-[8px]'>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
Description
|
||||
</span>
|
||||
<span className='text-right text-[11px] text-[var(--text-secondary)]'>
|
||||
{tool.toolDescription || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start justify-between gap-[8px]'>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
Parameters ({toolParams.length})
|
||||
</span>
|
||||
<div className='flex flex-wrap justify-end gap-[4px]'>
|
||||
{toolParams.length === 0 ? (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>None</span>
|
||||
) : (
|
||||
toolParams.map((param) => (
|
||||
<Badge key={param} variant='outline' className='text-[9px]'>
|
||||
{param}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function McpToolDeploy({
|
||||
workflowId,
|
||||
workflowName,
|
||||
workflowDescription,
|
||||
isDeployed,
|
||||
onAddedToServer,
|
||||
}: McpToolDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const {
|
||||
data: servers = [],
|
||||
isLoading: isLoadingServers,
|
||||
refetch: refetchServers,
|
||||
} = useWorkflowMcpServers(workspaceId)
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
|
||||
// Get workflow blocks
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
|
||||
// Find the starter block ID to subscribe to its inputFormat changes
|
||||
const starterBlockId = useMemo(() => {
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const blockType = (block as { type?: string }).type
|
||||
// Check for all possible start/trigger block types
|
||||
if (
|
||||
blockType === 'starter' ||
|
||||
blockType === 'start' ||
|
||||
blockType === 'start_trigger' || // This is the unified start block type
|
||||
blockType === 'api' ||
|
||||
blockType === 'api_trigger' ||
|
||||
blockType === 'input_trigger'
|
||||
) {
|
||||
return blockId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [blocks])
|
||||
|
||||
// Subscribe to the inputFormat value in SubBlockStore for reactivity
|
||||
// Use workflowId prop directly (not activeWorkflowId from registry) to ensure we get the correct workflow's data
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
|
||||
)
|
||||
|
||||
// Extract and normalize input format - now reactive to SubBlockStore changes
|
||||
const inputFormat = useMemo(() => {
|
||||
// First try to get from SubBlockStore (where runtime values are stored)
|
||||
if (starterBlockId && subBlockValues[starterBlockId]) {
|
||||
const inputFormatValue = subBlockValues[starterBlockId].inputFormat
|
||||
|
||||
if (Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
const filtered = inputFormatValue
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
if (filtered.length > 0) {
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to get from block structure (for initial load or backwards compatibility)
|
||||
if (starterBlockId && blocks[starterBlockId]) {
|
||||
const startBlock = blocks[starterBlockId]
|
||||
const subBlocksValue = startBlock?.subBlocks?.inputFormat?.value as unknown
|
||||
|
||||
if (Array.isArray(subBlocksValue) && subBlocksValue.length > 0) {
|
||||
const validFields: Array<{ name: string; type: string }> = []
|
||||
for (const field of subBlocksValue) {
|
||||
if (
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof field.name === 'string' &&
|
||||
field.name.trim() !== ''
|
||||
) {
|
||||
validFields.push({
|
||||
name: field.name.trim(),
|
||||
type: typeof field.type === 'string' ? field.type : 'string',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (validFields.length > 0) {
|
||||
return validFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last fallback: use extractInputFormat helper
|
||||
return extractInputFormat(blocks)
|
||||
}, [starterBlockId, subBlockValues, blocks])
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<WorkflowMcpServer | null>(null)
|
||||
const [toolName, setToolName] = useState('')
|
||||
const [toolDescription, setToolDescription] = useState('')
|
||||
const [showServerSelector, setShowServerSelector] = useState(false)
|
||||
const [showParameterSchema, setShowParameterSchema] = useState(false)
|
||||
// Track custom descriptions for each parameter
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
|
||||
const parameterSchema = useMemo(
|
||||
() => generateParameterSchema(inputFormat, parameterDescriptions),
|
||||
[inputFormat, parameterDescriptions]
|
||||
)
|
||||
|
||||
// Track tools data from each server using state instead of hooks in a loop
|
||||
const [serverToolsMap, setServerToolsMap] = useState<
|
||||
Record<string, { tool: WorkflowMcpTool | null; isLoading: boolean }>
|
||||
>({})
|
||||
|
||||
// Stable callback to handle tool data from ServerToolsQuery components
|
||||
const handleServerToolData = useCallback(
|
||||
(serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => {
|
||||
setServerToolsMap((prev) => {
|
||||
// Only update if data has changed to prevent infinite loops
|
||||
const existing = prev[serverId]
|
||||
if (existing?.tool?.id === tool?.id && existing?.isLoading === isLoading) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[serverId]: { tool, isLoading },
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Find which servers already have this workflow as a tool and get the tool info
|
||||
const serversWithThisWorkflow = useMemo(() => {
|
||||
const result: Array<{ server: WorkflowMcpServer; tool: WorkflowMcpTool }> = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
result.push({ server, tool: toolInfo.tool })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [servers, serverToolsMap])
|
||||
|
||||
// Check if any tools need updating
|
||||
const toolsNeedingUpdate = useMemo(() => {
|
||||
return serversWithThisWorkflow.filter(({ tool }) => hasParameterMismatch(tool, inputFormat))
|
||||
}, [serversWithThisWorkflow, inputFormat])
|
||||
|
||||
// Load existing parameter descriptions from the first deployed tool
|
||||
useEffect(() => {
|
||||
if (serversWithThisWorkflow.length > 0) {
|
||||
const existingTool = serversWithThisWorkflow[0].tool
|
||||
const schema = existingTool.parameterSchema as Record<string, unknown> | undefined
|
||||
const properties = schema?.properties as Record<string, { description?: string }> | undefined
|
||||
|
||||
if (properties) {
|
||||
const descriptions: Record<string, string> = {}
|
||||
for (const [name, prop] of Object.entries(properties)) {
|
||||
// Only use description if it differs from the field name (i.e., it's custom)
|
||||
if (
|
||||
prop.description &&
|
||||
prop.description !== name &&
|
||||
prop.description !== 'Array of file objects'
|
||||
) {
|
||||
descriptions[name] = prop.description
|
||||
}
|
||||
}
|
||||
if (Object.keys(descriptions).length > 0) {
|
||||
setParameterDescriptions(descriptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [serversWithThisWorkflow])
|
||||
|
||||
// Reset form when selected server changes
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
setToolName(sanitizeToolName(workflowName))
|
||||
setToolDescription(workflowDescription || `Execute ${workflowName} workflow`)
|
||||
}
|
||||
}, [selectedServer, workflowName, workflowDescription])
|
||||
|
||||
const handleAddTool = useCallback(async () => {
|
||||
if (!selectedServer || !toolName.trim()) return
|
||||
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: selectedServer.id,
|
||||
workflowId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
|
||||
setSelectedServer(null)
|
||||
setToolName('')
|
||||
setToolDescription('')
|
||||
|
||||
// Refetch servers to update tool count
|
||||
refetchServers()
|
||||
onAddedToServer?.()
|
||||
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${selectedServer.id}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add tool:', error)
|
||||
}
|
||||
}, [
|
||||
selectedServer,
|
||||
toolName,
|
||||
toolDescription,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
parameterSchema,
|
||||
addToolMutation,
|
||||
refetchServers,
|
||||
onAddedToServer,
|
||||
])
|
||||
|
||||
const handleToolChanged = useCallback(
|
||||
(removedServerId?: string) => {
|
||||
// If a tool was removed from a specific server, clear just that entry
|
||||
// The ServerToolsQuery component will re-query and update the map
|
||||
if (removedServerId) {
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[removedServerId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
refetchServers()
|
||||
},
|
||||
[refetchServers]
|
||||
)
|
||||
|
||||
const availableServers = useMemo(() => {
|
||||
const addedServerIds = new Set(serversWithThisWorkflow.map((s) => s.server.id))
|
||||
return servers.filter((server) => !addedServerIds.has(server.id))
|
||||
}, [servers, serversWithThisWorkflow])
|
||||
|
||||
if (!isDeployed) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[12px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>Deploy workflow first</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
You need to deploy your workflow before adding it as an MCP tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingServers) {
|
||||
return (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[60px] w-full' />
|
||||
<Skeleton className='h-[40px] w-full' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[12px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>No MCP servers yet</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create a Workflow MCP Server in Settings → Workflow MCP Servers first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{/* Query tools for each server using separate components to follow Rules of Hooks */}
|
||||
{servers.map((server) => (
|
||||
<ServerToolsQuery
|
||||
key={server.id}
|
||||
workspaceId={workspaceId}
|
||||
server={server}
|
||||
workflowId={workflowId}
|
||||
onData={handleServerToolData}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Add this workflow as an MCP tool to make it callable by external MCP clients like Cursor
|
||||
or Claude Desktop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Update Warning */}
|
||||
{toolsNeedingUpdate.length > 0 && (
|
||||
<div className='flex items-center gap-[8px] rounded-[6px] border border-amber-500/30 bg-amber-500/10 px-[10px] py-[8px]'>
|
||||
<AlertTriangle className='h-[14px] w-[14px] flex-shrink-0 text-amber-500' />
|
||||
<p className='text-[12px] text-amber-600 dark:text-amber-400'>
|
||||
{toolsNeedingUpdate.length} server{toolsNeedingUpdate.length > 1 ? 's have' : ' has'}{' '}
|
||||
outdated tool definitions. Click "Update" on each to sync with current parameters.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameter Schema Preview */}
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowParameterSchema(!showParameterSchema)}
|
||||
className='flex items-center gap-[6px] text-left'
|
||||
>
|
||||
{showParameterSchema ? (
|
||||
<ChevronDown className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<Label className='cursor-pointer text-[13px] text-[var(--text-primary)]'>
|
||||
Current Tool Parameters ({inputFormat.length})
|
||||
</Label>
|
||||
</button>
|
||||
|
||||
{showParameterSchema && (
|
||||
<div className='rounded-[6px] border bg-[var(--surface-4)] p-[12px]'>
|
||||
{inputFormat.length === 0 ? (
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
No parameters defined. Add input fields in the Starter block to define tool
|
||||
parameters.
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{inputFormat.map((field, index) => (
|
||||
<div key={index} className='flex flex-col gap-[6px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
{field.name}
|
||||
</span>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{field.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<EmcnInput
|
||||
value={parameterDescriptions[field.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParameterDescriptions((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Describe what "${field.name}" is for...`}
|
||||
className='h-[32px] text-[12px]'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Descriptions help MCP clients understand what each parameter is for.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Servers with this workflow */}
|
||||
{serversWithThisWorkflow.length > 0 && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>
|
||||
Added to ({serversWithThisWorkflow.length})
|
||||
</Label>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
{serversWithThisWorkflow.map(({ server, tool }) => (
|
||||
<ToolOnServer
|
||||
key={server.id}
|
||||
server={server}
|
||||
tool={tool}
|
||||
workspaceId={workspaceId}
|
||||
currentInputFormat={inputFormat}
|
||||
currentParameterSchema={parameterSchema}
|
||||
workflowDescription={workflowDescription}
|
||||
onRemoved={(serverId) => handleToolChanged(serverId)}
|
||||
onUpdated={() => handleToolChanged()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to new server */}
|
||||
{availableServers.length > 0 ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Add to Server</Label>
|
||||
<Popover open={showServerSelector} onOpenChange={setShowServerSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[36px] w-full justify-between border bg-[var(--surface-3)]'
|
||||
>
|
||||
<span className={cn(!selectedServer && 'text-[var(--text-muted)]')}>
|
||||
{selectedServer?.name || 'Choose a server...'}
|
||||
</span>
|
||||
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
className='w-[var(--radix-popover-trigger-width)]'
|
||||
border
|
||||
>
|
||||
{availableServers.map((server) => (
|
||||
<PopoverItem
|
||||
key={server.id}
|
||||
onClick={() => {
|
||||
setSelectedServer(server)
|
||||
setShowServerSelector(false)
|
||||
}}
|
||||
>
|
||||
<Server className='mr-[8px] h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
<span>{server.name}</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='ml-auto text-[10px]'>
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{selectedServer && (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Tool Name</Label>
|
||||
<EmcnInput
|
||||
value={toolName}
|
||||
onChange={(e) => setToolName(e.target.value)}
|
||||
placeholder='e.g., book_flight'
|
||||
className='h-[36px]'
|
||||
/>
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Use lowercase letters, numbers, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Description</Label>
|
||||
<EmcnInput
|
||||
value={toolDescription}
|
||||
onChange={(e) => setToolDescription(e.target.value)}
|
||||
placeholder='Describe what this tool does...'
|
||||
className='h-[36px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleAddTool}
|
||||
disabled={addToolMutation.isPending || !toolName.trim()}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[14px] w-[14px]' />
|
||||
{addToolMutation.isPending ? 'Adding...' : 'Add to Server'}
|
||||
</Button>
|
||||
|
||||
{addToolMutation.isError && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{addToolMutation.error?.message || 'Failed to add tool'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : serversWithThisWorkflow.length > 0 ? (
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
This workflow has been added to all available servers.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { ApiDeploy } from './components/api/api'
|
||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||
import { GeneralDeploy } from './components/general/general'
|
||||
import { McpToolDeploy } from './components/mcp-tool/mcp-tool'
|
||||
import { TemplateDeploy } from './components/template/template'
|
||||
|
||||
const logger = createLogger('DeployModal')
|
||||
@@ -50,7 +49,7 @@ interface WorkflowDeploymentInfo {
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp-tool'
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template'
|
||||
|
||||
export function DeployModal({
|
||||
open,
|
||||
@@ -553,7 +552,6 @@ export function DeployModal({
|
||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='mcp-tool'>MCP Tool</ModalTabsTrigger>
|
||||
</ModalTabsList>
|
||||
|
||||
<ModalBody className='min-h-0 flex-1'>
|
||||
@@ -612,17 +610,6 @@ export function DeployModal({
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='mcp-tool'>
|
||||
{workflowId && (
|
||||
<McpToolDeploy
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||
workflowDescription={workflowMetadata?.description}
|
||||
isDeployed={isDeployed}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
</ModalTabs>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-
|
||||
import type { GenerationType } from '@/blocks/types'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/use-tag-selection'
|
||||
import { useTextHistory } from '@/hooks/use-text-history'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
|
||||
const logger = createLogger('Code')
|
||||
@@ -305,6 +306,20 @@ export function Code({
|
||||
},
|
||||
})
|
||||
|
||||
// Text history for undo/redo with debouncing
|
||||
const textHistory = useTextHistory({
|
||||
blockId,
|
||||
subBlockId,
|
||||
value: code,
|
||||
onChange: (newValue) => {
|
||||
setCode(newValue)
|
||||
if (!isPreview && !disabled) {
|
||||
setStoreValue(newValue)
|
||||
}
|
||||
},
|
||||
disabled: isPreview || disabled || readOnly || isAiStreaming,
|
||||
})
|
||||
|
||||
const getDefaultValueString = () => {
|
||||
if (defaultValue === undefined || defaultValue === null) return ''
|
||||
if (typeof defaultValue === 'string') return defaultValue
|
||||
@@ -348,10 +363,12 @@ export function Code({
|
||||
useEffect(() => {
|
||||
handleStreamStartRef.current = () => {
|
||||
setCode('')
|
||||
lastInternalValueRef.current = ''
|
||||
}
|
||||
|
||||
handleGeneratedContentRef.current = (generatedCode: string) => {
|
||||
setCode(generatedCode)
|
||||
lastInternalValueRef.current = generatedCode
|
||||
if (!isPreview && !disabled) {
|
||||
setStoreValue(generatedCode)
|
||||
}
|
||||
@@ -387,14 +404,21 @@ export function Code({
|
||||
}
|
||||
}, [readOnly])
|
||||
|
||||
// Effects: Sync code with external value
|
||||
// Ref to track the last value we set internally (to avoid sync loops)
|
||||
const lastInternalValueRef = useRef<string>('')
|
||||
|
||||
// Effects: Sync code with external value (only for truly external changes)
|
||||
useEffect(() => {
|
||||
if (isAiStreaming) return
|
||||
const valueString = value?.toString() ?? ''
|
||||
if (valueString !== code) {
|
||||
|
||||
// Only sync if this is a genuine external change, not our own update
|
||||
// This prevents resetting the undo history when we update the store
|
||||
if (valueString !== code && valueString !== lastInternalValueRef.current) {
|
||||
setCode(valueString)
|
||||
lastInternalValueRef.current = valueString
|
||||
}
|
||||
}, [value, code, isAiStreaming])
|
||||
}, [value, isAiStreaming]) // Removed 'code' from dependencies to prevent sync loops
|
||||
|
||||
// Effects: Track active line number for cursor position
|
||||
useEffect(() => {
|
||||
@@ -502,8 +526,9 @@ export function Code({
|
||||
const dropPosition = textarea?.selectionStart ?? code.length
|
||||
const newValue = `${code.slice(0, dropPosition)}<${code.slice(dropPosition)}`
|
||||
|
||||
setCode(newValue)
|
||||
setStoreValue(newValue)
|
||||
// Use textHistory for proper undo tracking
|
||||
textHistory.handleChange(newValue)
|
||||
lastInternalValueRef.current = newValue
|
||||
const newCursorPosition = dropPosition + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
@@ -531,7 +556,9 @@ export function Code({
|
||||
*/
|
||||
const handleTagSelect = (newValue: string) => {
|
||||
if (!isPreview && !readOnly) {
|
||||
setCode(newValue)
|
||||
// Use textHistory for proper undo tracking
|
||||
textHistory.handleChange(newValue)
|
||||
lastInternalValueRef.current = newValue
|
||||
emitTagSelection(newValue)
|
||||
}
|
||||
setShowTags(false)
|
||||
@@ -548,7 +575,9 @@ export function Code({
|
||||
*/
|
||||
const handleEnvVarSelect = (newValue: string) => {
|
||||
if (!isPreview && !readOnly) {
|
||||
setCode(newValue)
|
||||
// Use textHistory for proper undo tracking
|
||||
textHistory.handleChange(newValue)
|
||||
lastInternalValueRef.current = newValue
|
||||
emitTagSelection(newValue)
|
||||
}
|
||||
setShowEnvVars(false)
|
||||
@@ -741,8 +770,10 @@ export function Code({
|
||||
value={code}
|
||||
onValueChange={(newCode) => {
|
||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||
setCode(newCode)
|
||||
setStoreValue(newCode)
|
||||
// Use textHistory for debounced undo/redo tracking
|
||||
textHistory.handleChange(newCode)
|
||||
// Track this as an internal change to prevent sync loops
|
||||
lastInternalValueRef.current = newCode
|
||||
|
||||
const textarea = editorRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
@@ -762,6 +793,10 @@ export function Code({
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Let text history handle undo/redo first
|
||||
if (textHistory.handleKeyDown(e)) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowTags(false)
|
||||
setShowEnvVars(false)
|
||||
@@ -770,6 +805,10 @@ export function Code({
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Commit any pending text history changes on blur
|
||||
textHistory.handleBlur()
|
||||
}}
|
||||
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
@@ -315,14 +316,28 @@ export function OAuthRequiredModal({
|
||||
}
|
||||
}
|
||||
|
||||
const displayScopes = requiredScopes.filter(
|
||||
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
|
||||
const newScopesSet = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(newScopes || []).filter(
|
||||
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
|
||||
)
|
||||
),
|
||||
[newScopes]
|
||||
)
|
||||
const newScopesSet = new Set(
|
||||
(newScopes || []).filter(
|
||||
|
||||
const displayScopes = useMemo(() => {
|
||||
const filtered = requiredScopes.filter(
|
||||
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
|
||||
)
|
||||
)
|
||||
return filtered.sort((a, b) => {
|
||||
const aIsNew = newScopesSet.has(a)
|
||||
const bIsNew = newScopesSet.has(b)
|
||||
if (aIsNew && !bIsNew) return -1
|
||||
if (!aIsNew && bIsNew) return 1
|
||||
return 0
|
||||
})
|
||||
}, [requiredScopes, newScopesSet])
|
||||
|
||||
const handleConnectDirectly = async () => {
|
||||
try {
|
||||
@@ -347,13 +362,6 @@ export function OAuthRequiredModal({
|
||||
return
|
||||
}
|
||||
|
||||
if (providerId === 'servicenow') {
|
||||
// Pass the current URL so we can redirect back after OAuth
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}`
|
||||
return
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
providerId,
|
||||
callbackURL: window.location.href,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
@@ -45,10 +46,14 @@ export function CredentialSelector({
|
||||
const label = subBlock.placeholder || 'Select credential'
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
|
||||
const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const hasDependencies = dependsOn.length > 0
|
||||
|
||||
const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied)
|
||||
|
||||
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
|
||||
|
||||
// serviceId is now the canonical identifier - derive provider from it
|
||||
const effectiveProviderId = useMemo(
|
||||
() => getProviderIdFromServiceId(serviceId) as OAuthProvider,
|
||||
[serviceId]
|
||||
@@ -130,7 +135,7 @@ export function CredentialSelector({
|
||||
const needsUpdate =
|
||||
hasSelection &&
|
||||
missingRequiredScopes.length > 0 &&
|
||||
!disabled &&
|
||||
!effectiveDisabled &&
|
||||
!isPreview &&
|
||||
!credentialsLoading
|
||||
|
||||
@@ -230,8 +235,10 @@ export function CredentialSelector({
|
||||
selectedValue={selectedId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
placeholder={label}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
hasDependencies && !depsSatisfied ? 'Fill in required fields above first' : label
|
||||
}
|
||||
disabled={effectiveDisabled}
|
||||
editable={true}
|
||||
filterOptions={true}
|
||||
isLoading={credentialsLoading}
|
||||
|
||||
@@ -85,6 +85,7 @@ export function ShortInput({
|
||||
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
|
||||
|
||||
const justPastedRef = useRef(false)
|
||||
|
||||
const webhookManagement = useWebhookManagement({
|
||||
blockId,
|
||||
triggerId: undefined,
|
||||
|
||||
@@ -844,8 +844,13 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
if (!accessibleBlock) continue
|
||||
|
||||
// Skip the current block - blocks cannot reference their own outputs
|
||||
// Exception: approval blocks can reference their own outputs
|
||||
if (accessibleBlockId === blockId && accessibleBlock.type !== 'approval') continue
|
||||
// Exception: approval and human_in_the_loop blocks can reference their own outputs
|
||||
if (
|
||||
accessibleBlockId === blockId &&
|
||||
accessibleBlock.type !== 'approval' &&
|
||||
accessibleBlock.type !== 'human_in_the_loop'
|
||||
)
|
||||
continue
|
||||
|
||||
const blockConfig = getBlock(accessibleBlock.type)
|
||||
|
||||
@@ -972,6 +977,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
|
||||
}
|
||||
} else if (accessibleBlock.type === 'human_in_the_loop') {
|
||||
blockTags = [`${normalizedBlockName}.url`]
|
||||
} else {
|
||||
const operationValue =
|
||||
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
|
||||
@@ -1214,31 +1221,25 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
|
||||
let processedTag = tag
|
||||
|
||||
// Check if this is a file property and add [0] automatically
|
||||
// Only include user-accessible fields (matches UserFile interface)
|
||||
const fileProperties = ['id', 'name', 'url', 'size', 'type']
|
||||
const parts = tag.split('.')
|
||||
if (parts.length >= 2 && fileProperties.includes(parts[parts.length - 1])) {
|
||||
const fieldName = parts[parts.length - 2]
|
||||
if (parts.length >= 3 && blockGroup) {
|
||||
const arrayFieldName = parts[1] // e.g., "channels", "files", "users"
|
||||
const block = useWorkflowStore.getState().blocks[blockGroup.blockId]
|
||||
const blockConfig = block ? (getBlock(block.type) ?? null) : null
|
||||
const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId)
|
||||
|
||||
if (blockGroup) {
|
||||
const block = useWorkflowStore.getState().blocks[blockGroup.blockId]
|
||||
const blockConfig = block ? (getBlock(block.type) ?? null) : null
|
||||
const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId)
|
||||
const fieldType = getOutputTypeForPath(
|
||||
block,
|
||||
blockConfig,
|
||||
blockGroup.blockId,
|
||||
arrayFieldName,
|
||||
mergedSubBlocks
|
||||
)
|
||||
|
||||
const fieldType = getOutputTypeForPath(
|
||||
block,
|
||||
blockConfig,
|
||||
blockGroup.blockId,
|
||||
fieldName,
|
||||
mergedSubBlocks
|
||||
)
|
||||
|
||||
if (fieldType === 'files') {
|
||||
const blockAndField = parts.slice(0, -1).join('.')
|
||||
const property = parts[parts.length - 1]
|
||||
processedTag = `${blockAndField}[0].${property}`
|
||||
}
|
||||
if (fieldType === 'files' || fieldType === 'array') {
|
||||
const blockName = parts[0]
|
||||
const remainingPath = parts.slice(2).join('.')
|
||||
processedTag = `${blockName}.${arrayFieldName}[0].${remainingPath}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import 'prismjs/components/prism-json'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
import { useTextHistoryStore } from '@/stores/text-history'
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string
|
||||
@@ -33,6 +34,11 @@ interface CodeEditorProps {
|
||||
showWandButton?: boolean
|
||||
onWandClick?: () => void
|
||||
wandButtonDisabled?: boolean
|
||||
/**
|
||||
* Unique identifier for text history. When provided, enables undo/redo functionality.
|
||||
* Format: "blockId:fieldName" e.g. "block-123:schema" or "block-123:code"
|
||||
*/
|
||||
historyId?: string
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
@@ -50,16 +56,125 @@ export function CodeEditor({
|
||||
showWandButton = false,
|
||||
onWandClick,
|
||||
wandButtonDisabled = false,
|
||||
historyId,
|
||||
}: CodeEditorProps) {
|
||||
const [code, setCode] = useState(value)
|
||||
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const lastInternalValueRef = useRef<string>(value)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
// Text history store for undo/redo
|
||||
const textHistoryStore = useTextHistoryStore()
|
||||
|
||||
// Parse historyId into blockId and subBlockId for the store
|
||||
const [historyBlockId, historySubBlockId] = historyId?.split(':') ?? ['', '']
|
||||
const hasHistory = Boolean(historyId && historyBlockId && historySubBlockId)
|
||||
|
||||
// Initialize history on mount
|
||||
useEffect(() => {
|
||||
setCode(value)
|
||||
if (hasHistory && !initializedRef.current) {
|
||||
textHistoryStore.initHistory(historyBlockId, historySubBlockId, value)
|
||||
initializedRef.current = true
|
||||
}
|
||||
}, [hasHistory, historyBlockId, historySubBlockId, value, textHistoryStore])
|
||||
|
||||
// Sync external value changes (but avoid resetting undo history for internal changes)
|
||||
useEffect(() => {
|
||||
if (value !== code && value !== lastInternalValueRef.current) {
|
||||
setCode(value)
|
||||
lastInternalValueRef.current = value
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Handle value change with history tracking
|
||||
const handleValueChange = useCallback(
|
||||
(newCode: string) => {
|
||||
setCode(newCode)
|
||||
lastInternalValueRef.current = newCode
|
||||
onChange(newCode)
|
||||
|
||||
// Record to history if enabled
|
||||
if (hasHistory) {
|
||||
textHistoryStore.recordChange(historyBlockId, historySubBlockId, newCode)
|
||||
}
|
||||
},
|
||||
[onChange, hasHistory, historyBlockId, historySubBlockId, textHistoryStore]
|
||||
)
|
||||
|
||||
// Handle undo
|
||||
const handleUndo = useCallback(() => {
|
||||
if (!hasHistory) return false
|
||||
|
||||
const previousValue = textHistoryStore.undo(historyBlockId, historySubBlockId)
|
||||
if (previousValue !== null) {
|
||||
setCode(previousValue)
|
||||
lastInternalValueRef.current = previousValue
|
||||
onChange(previousValue)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore, onChange])
|
||||
|
||||
// Handle redo
|
||||
const handleRedo = useCallback(() => {
|
||||
if (!hasHistory) return false
|
||||
|
||||
const nextValue = textHistoryStore.redo(historyBlockId, historySubBlockId)
|
||||
if (nextValue !== null) {
|
||||
setCode(nextValue)
|
||||
lastInternalValueRef.current = nextValue
|
||||
onChange(nextValue)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore, onChange])
|
||||
|
||||
// Handle keyboard events for undo/redo
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (disabled) return
|
||||
|
||||
const isMod = e.metaKey || e.ctrlKey
|
||||
|
||||
// Undo: Cmd+Z / Ctrl+Z
|
||||
if (isMod && e.key === 'z' && !e.shiftKey && hasHistory) {
|
||||
if (handleUndo()) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redo: Cmd+Shift+Z / Ctrl+Shift+Z / Ctrl+Y
|
||||
if (hasHistory) {
|
||||
if (
|
||||
(isMod && e.key === 'z' && e.shiftKey) ||
|
||||
(isMod && e.key === 'Z') ||
|
||||
(e.ctrlKey && e.key === 'y')
|
||||
) {
|
||||
if (handleRedo()) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call parent's onKeyDown if provided
|
||||
onKeyDown?.(e)
|
||||
},
|
||||
[disabled, hasHistory, handleUndo, handleRedo, onKeyDown]
|
||||
)
|
||||
|
||||
// Handle blur - commit pending history
|
||||
const handleBlur = useCallback(() => {
|
||||
if (hasHistory) {
|
||||
textHistoryStore.commitPending(historyBlockId, historySubBlockId)
|
||||
}
|
||||
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
@@ -211,11 +326,9 @@ export function CodeEditor({
|
||||
|
||||
<Editor
|
||||
value={code}
|
||||
onValueChange={(newCode) => {
|
||||
setCode(newCode)
|
||||
onChange(newCode)
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onValueChange={handleValueChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
highlight={(code) => customHighlight(code)}
|
||||
disabled={disabled}
|
||||
{...getCodeEditorProps({ disabled })}
|
||||
|
||||
@@ -936,6 +936,7 @@ try {
|
||||
gutterClassName='bg-[var(--bg)]'
|
||||
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
|
||||
onKeyDown={handleKeyDown}
|
||||
historyId={`${blockId}:tool-schema`}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
@@ -1018,6 +1019,7 @@ try {
|
||||
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
|
||||
onKeyDown={handleKeyDown}
|
||||
schemaParameters={schemaParameters}
|
||||
historyId={`${blockId}:tool-code`}
|
||||
/>
|
||||
|
||||
{showEnvVars && (
|
||||
|
||||
@@ -74,6 +74,7 @@ export function TriggerSave({
|
||||
blockId,
|
||||
triggerId: effectiveTriggerId,
|
||||
isPreview,
|
||||
useWebhookUrl: true, // to store the webhook url in the store
|
||||
})
|
||||
|
||||
const triggerConfig = useSubBlockStore((state) => state.getValue(blockId, 'triggerConfig'))
|
||||
|
||||
@@ -40,6 +40,8 @@ import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('WorkflowBlock')
|
||||
|
||||
@@ -844,7 +846,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data-handleid='target'
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={true}
|
||||
isValidConnection={(connection) => connection.source !== id}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.source === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1045,7 +1051,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data-handleid={`condition-${cond.id}`}
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => connection.target !== id}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -1064,7 +1074,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => connection.target !== id}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -1081,7 +1095,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data-handleid='source'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => connection.target !== id}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
@@ -1100,7 +1118,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => connection.target !== id}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,61 @@ import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const logger = createLogger('NodeUtilities')
|
||||
|
||||
/**
|
||||
* Estimates block dimensions based on block type.
|
||||
* Uses subblock count to estimate height for blocks that haven't been measured yet.
|
||||
*
|
||||
* @param blockType - The type of block (e.g., 'condition', 'agent')
|
||||
* @returns Estimated width and height for the block
|
||||
*/
|
||||
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
|
||||
const blockConfig = getBlock(blockType)
|
||||
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
|
||||
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
|
||||
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
|
||||
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
|
||||
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
|
||||
|
||||
const height =
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT +
|
||||
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
|
||||
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a position to keep a block fully inside a container's content area.
|
||||
* Content area starts after the header and padding, and ends before the right/bottom padding.
|
||||
*
|
||||
* @param position - Raw position relative to container origin
|
||||
* @param containerDimensions - Container width and height
|
||||
* @param blockDimensions - Block width and height
|
||||
* @returns Clamped position that keeps block inside content area
|
||||
*/
|
||||
export function clampPositionToContainer(
|
||||
position: { x: number; y: number },
|
||||
containerDimensions: { width: number; height: number },
|
||||
blockDimensions: { width: number; height: number }
|
||||
): { x: number; y: number } {
|
||||
const { width: containerWidth, height: containerHeight } = containerDimensions
|
||||
const { width: blockWidth, height: blockHeight } = blockDimensions
|
||||
|
||||
// Content area bounds (where blocks can be placed)
|
||||
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
|
||||
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
|
||||
|
||||
return {
|
||||
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
|
||||
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing utilities for node position, hierarchy, and dimension calculations
|
||||
*/
|
||||
@@ -21,7 +76,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
|
||||
/**
|
||||
* Get the dimensions of a block.
|
||||
* For regular blocks, estimates height based on block config if not yet measured.
|
||||
* For regular blocks, uses stored height or estimates based on block config.
|
||||
*/
|
||||
const getBlockDimensions = useCallback(
|
||||
(blockId: string): { width: number; height: number } => {
|
||||
@@ -41,32 +96,16 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow block nodes have fixed visual width
|
||||
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
|
||||
// Prefer deterministic height published by the block component; fallback to estimate
|
||||
let height = block.height
|
||||
|
||||
if (!height) {
|
||||
// Estimate height based on block config's subblock count for more accurate initial sizing
|
||||
// This is critical for subflow containers to size correctly before child blocks are measured
|
||||
const blockConfig = getBlock(block.type)
|
||||
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
|
||||
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
|
||||
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
|
||||
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
|
||||
const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0
|
||||
|
||||
height =
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT +
|
||||
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
|
||||
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
if (block.height) {
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
// Use shared estimation utility for blocks without measured height
|
||||
return estimateBlockDimensions(block.type)
|
||||
},
|
||||
[blocks, isContainerType]
|
||||
)
|
||||
@@ -164,29 +203,36 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
)
|
||||
|
||||
/**
|
||||
* Calculates the relative position of a node to a new parent's content area.
|
||||
* Accounts for header height and padding offsets in container nodes.
|
||||
* Calculates the relative position of a node to a new parent's origin.
|
||||
* React Flow positions children relative to parent origin, so we clamp
|
||||
* to the content area bounds (after header and padding).
|
||||
* @param nodeId ID of the node being repositioned
|
||||
* @param newParentId ID of the new parent
|
||||
* @returns Relative position coordinates {x, y} within the parent's content area
|
||||
* @returns Relative position coordinates {x, y} within the parent
|
||||
*/
|
||||
const calculateRelativePosition = useCallback(
|
||||
(nodeId: string, newParentId: string): { x: number; y: number } => {
|
||||
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
|
||||
const parentAbsPos = getNodeAbsolutePosition(newParentId)
|
||||
const parentNode = getNodes().find((n) => n.id === newParentId)
|
||||
|
||||
// Account for container's header and padding
|
||||
// Children are positioned relative to content area, not container origin
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
|
||||
return {
|
||||
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
|
||||
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
|
||||
// Calculate raw relative position (relative to parent origin)
|
||||
const rawPosition = {
|
||||
x: nodeAbsPos.x - parentAbsPos.x,
|
||||
y: nodeAbsPos.y - parentAbsPos.y,
|
||||
}
|
||||
|
||||
// Get container and block dimensions
|
||||
const containerDimensions = {
|
||||
width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = getBlockDimensions(nodeId)
|
||||
|
||||
// Clamp position to keep block inside content area
|
||||
return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions)
|
||||
},
|
||||
[getNodeAbsolutePosition]
|
||||
[getNodeAbsolutePosition, getNodes, getBlockDimensions]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -252,7 +298,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
*/
|
||||
const calculateLoopDimensions = useCallback(
|
||||
(nodeId: string): { width: number; height: number } => {
|
||||
const childNodes = getNodes().filter((node) => node.parentId === nodeId)
|
||||
// Check both React Flow's node.parentId AND blocks store's data.parentId
|
||||
// This ensures we catch children even if React Flow hasn't re-rendered yet
|
||||
const childNodes = getNodes().filter(
|
||||
(node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId
|
||||
)
|
||||
if (childNodes.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
@@ -265,8 +315,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
maxRight = Math.max(maxRight, node.position.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, node.position.y + nodeHeight)
|
||||
// Use block position from store if available (more up-to-date)
|
||||
const block = blocks[node.id]
|
||||
const position = block?.position || node.position
|
||||
maxRight = Math.max(maxRight, position.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, position.y + nodeHeight)
|
||||
})
|
||||
|
||||
const width = Math.max(
|
||||
@@ -283,7 +336,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
|
||||
return { width, height }
|
||||
},
|
||||
[getNodes, getBlockDimensions]
|
||||
[getNodes, getBlockDimensions, blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
useCurrentWorkflow,
|
||||
useNodeUtilities,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
clampPositionToContainer,
|
||||
estimateBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/constants'
|
||||
@@ -694,17 +698,19 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate position relative to the container's content area
|
||||
// Account for header (50px), left padding (16px), and top padding (16px)
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
|
||||
const relativePosition = {
|
||||
x: position.x - containerInfo.loopPosition.x - leftPadding,
|
||||
y: position.y - containerInfo.loopPosition.y - headerHeight - topPadding,
|
||||
// Calculate raw position relative to container origin
|
||||
const rawPosition = {
|
||||
x: position.x - containerInfo.loopPosition.x,
|
||||
y: position.y - containerInfo.loopPosition.y,
|
||||
}
|
||||
|
||||
// Clamp position to keep block inside container's content area
|
||||
const relativePosition = clampPositionToContainer(
|
||||
rawPosition,
|
||||
containerInfo.dimensions,
|
||||
estimateBlockDimensions(data.type)
|
||||
)
|
||||
|
||||
// Capture existing child blocks before adding the new one
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === containerInfo.loopId
|
||||
@@ -1642,11 +1648,6 @@ const WorkflowContent = React.memo(() => {
|
||||
const onConnect = useCallback(
|
||||
(connection: any) => {
|
||||
if (connection.source && connection.target) {
|
||||
// Prevent self-connections
|
||||
if (connection.source === connection.target) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connecting nodes across container boundaries
|
||||
const sourceNode = getNodes().find((n) => n.id === connection.source)
|
||||
const targetNode = getNodes().find((n) => n.id === connection.target)
|
||||
@@ -1915,17 +1916,47 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
document.body.style.cursor = ''
|
||||
|
||||
// Get the block's current parent (if any)
|
||||
const currentBlock = blocks[node.id]
|
||||
const currentParentId = currentBlock?.data?.parentId
|
||||
|
||||
// Calculate position - clamp if inside a container
|
||||
let finalPosition = node.position
|
||||
if (currentParentId) {
|
||||
// Block is inside a container - clamp position to keep it fully inside
|
||||
const parentNode = getNodes().find((n) => n.id === currentParentId)
|
||||
if (parentNode) {
|
||||
const containerDimensions = {
|
||||
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(
|
||||
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
),
|
||||
}
|
||||
|
||||
finalPosition = clampPositionToContainer(
|
||||
node.position,
|
||||
containerDimensions,
|
||||
blockDimensions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit collaborative position update for the final position
|
||||
// This ensures other users see the smooth final position
|
||||
collaborativeUpdateBlockPosition(node.id, node.position, true)
|
||||
collaborativeUpdateBlockPosition(node.id, finalPosition, true)
|
||||
|
||||
// Record single move entry on drag end to avoid micro-moves
|
||||
const start = getDragStartPosition()
|
||||
if (start && start.id === node.id) {
|
||||
const before = { x: start.x, y: start.y, parentId: start.parentId }
|
||||
const after = {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
x: finalPosition.x,
|
||||
y: finalPosition.y,
|
||||
parentId: node.parentId || blocks[node.id]?.data?.parentId,
|
||||
}
|
||||
const moved =
|
||||
|
||||
@@ -9,4 +9,3 @@ export { MCP } from './mcp/mcp'
|
||||
export { SSO } from './sso/sso'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
export { TeamManagement } from './team-management/team-management'
|
||||
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Check, ChevronLeft, Clipboard, Globe, Plus, Search, Server, Trash2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
useCreateWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpTool,
|
||||
usePublishWorkflowMcpServer,
|
||||
useUnpublishWorkflowMcpServer,
|
||||
useWorkflowMcpServer,
|
||||
useWorkflowMcpServers,
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServers')
|
||||
|
||||
function ServerSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px] rounded-[8px] border bg-[var(--surface-3)] p-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
<Skeleton className='h-[12px] w-[80px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[28px] w-[60px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerListItemProps {
|
||||
server: WorkflowMcpServer
|
||||
onViewDetails: () => void
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
function ServerListItem({ server, onViewDetails, onDelete, isDeleting }: ServerListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center justify-between gap-[12px] rounded-[8px] border bg-[var(--surface-3)] p-[12px] transition-colors hover:bg-[var(--surface-4)]'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={onViewDetails}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onViewDetails()}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<Server className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<div className='flex min-w-0 flex-col gap-[2px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{server.name}
|
||||
</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='flex-shrink-0 text-[10px]'>
|
||||
<Globe className='mr-[4px] h-[10px] w-[10px]' />
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{server.toolCount || 0} tool{(server.toolCount || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className='h-[28px] px-[8px]'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerDetailViewProps {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
|
||||
const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId)
|
||||
const publishMutation = usePublishWorkflowMcpServer()
|
||||
const unpublishMutation = useUnpublishWorkflowMcpServer()
|
||||
const deleteToolMutation = useDeleteWorkflowMcpTool()
|
||||
const [copiedUrl, setCopiedUrl] = useState(false)
|
||||
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
|
||||
|
||||
const mcpServerUrl = useMemo(() => {
|
||||
if (!data?.server?.isPublished) return null
|
||||
return `${getBaseUrl()}/api/mcp/serve/${serverId}/sse`
|
||||
}, [data?.server?.isPublished, serverId])
|
||||
|
||||
const handlePublish = async () => {
|
||||
try {
|
||||
await publishMutation.mutateAsync({ workspaceId, serverId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to publish server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
try {
|
||||
await unpublishMutation.mutateAsync({ workspaceId, serverId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to unpublish server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (mcpServerUrl) {
|
||||
navigator.clipboard.writeText(mcpServerUrl)
|
||||
setCopiedUrl(true)
|
||||
setTimeout(() => setCopiedUrl(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTool = async () => {
|
||||
if (!toolToDelete) return
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolToDelete.id,
|
||||
})
|
||||
setToolToDelete(null)
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[24px] w-[200px]' />
|
||||
<Skeleton className='h-[100px] w-full' />
|
||||
<Skeleton className='h-[150px] w-full' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='text-[13px] text-[var(--text-error)]'>Failed to load server details</p>
|
||||
<Button variant='default' onClick={onBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { server, tools } = data
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Server Name
|
||||
</span>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
|
||||
</div>
|
||||
|
||||
{server.description && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Description
|
||||
</span>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>{server.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Status</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{server.isPublished ? (
|
||||
<>
|
||||
<Badge variant='outline' className='text-[12px]'>
|
||||
<Globe className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Published
|
||||
</Badge>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleUnpublish}
|
||||
disabled={unpublishMutation.isPending}
|
||||
className='h-[28px] text-[12px]'
|
||||
>
|
||||
{unpublishMutation.isPending ? 'Unpublishing...' : 'Unpublish'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className='text-[14px] text-[var(--text-tertiary)]'>Not Published</span>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handlePublish}
|
||||
disabled={publishMutation.isPending || tools.length === 0}
|
||||
className='h-[28px] text-[12px]'
|
||||
>
|
||||
{publishMutation.isPending ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{publishMutation.isError && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{publishMutation.error?.message || 'Failed to publish'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mcpServerUrl && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
MCP Server URL
|
||||
</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<code className='flex-1 truncate rounded-[4px] bg-[var(--surface-5)] px-[8px] py-[6px] font-mono text-[12px] text-[var(--text-secondary)]'>
|
||||
{mcpServerUrl}
|
||||
</code>
|
||||
<Button variant='ghost' onClick={handleCopyUrl} className='h-[32px] w-[32px] p-0'>
|
||||
{copiedUrl ? (
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Use this URL to connect external MCP clients like Cursor or Claude Desktop.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Tools ({tools.length})
|
||||
</span>
|
||||
{tools.length === 0 ? (
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No tools added yet. Deploy a workflow and add it as a tool from the deploy modal.
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className='flex items-center justify-between rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-[2px]'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.toolName}
|
||||
</p>
|
||||
{tool.toolDescription && (
|
||||
<p className='truncate text-[12px] text-[var(--text-tertiary)]'>
|
||||
{tool.toolDescription}
|
||||
</p>
|
||||
)}
|
||||
{tool.workflowName && (
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Workflow: {tool.workflowName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setToolToDelete(tool)}
|
||||
className='h-[24px] w-[24px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash2 className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto flex items-center justify-end'>
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<ChevronLeft className='mr-[4px] h-[14px] w-[14px]' />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={!!toolToDelete} onOpenChange={(open) => !open && setToolToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Remove Tool</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to remove{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{toolToDelete?.toolName}
|
||||
</span>{' '}
|
||||
from this server?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setToolToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDeleteTool}
|
||||
disabled={deleteToolMutation.isPending}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
{deleteToolMutation.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow MCP Servers settings component.
|
||||
* Allows users to create and manage MCP servers that expose workflows as tools.
|
||||
*/
|
||||
export function WorkflowMcpServers() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
const deleteServerMutation = useDeleteWorkflowMcpServer()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({ name: '', description: '' })
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!searchTerm.trim()) return servers
|
||||
const search = searchTerm.toLowerCase()
|
||||
return servers.filter(
|
||||
(server) =>
|
||||
server.name.toLowerCase().includes(search) ||
|
||||
server.description?.toLowerCase().includes(search)
|
||||
)
|
||||
}, [servers, searchTerm])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({ name: '', description: '' })
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const handleCreateServer = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
})
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
if (!serverToDelete) return
|
||||
|
||||
setDeletingServers((prev) => new Set(prev).add(serverToDelete.id))
|
||||
setServerToDelete(null)
|
||||
|
||||
try {
|
||||
await deleteServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: serverToDelete.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete server:', error)
|
||||
} finally {
|
||||
setDeletingServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverToDelete.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasServers = servers.length > 0
|
||||
const showEmptyState = !hasServers && !showAddForm
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
|
||||
const isFormValid = formData.name.trim().length > 0
|
||||
|
||||
// Show detail view if a server is selected
|
||||
if (selectedServerId) {
|
||||
return (
|
||||
<ServerDetailView
|
||||
workspaceId={workspaceId}
|
||||
serverId={selectedServerId}
|
||||
onBack={() => setSelectedServerId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-[8px] rounded-[8px] border bg-[var(--surface-6)] px-[8px] py-[5px]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search servers...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={isLoading}
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className='rounded-[8px] border bg-[var(--surface-3)] p-[12px]'>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<label
|
||||
htmlFor='mcp-server-name'
|
||||
className='font-medium text-[13px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Server Name
|
||||
</label>
|
||||
<EmcnInput
|
||||
id='mcp-server-name'
|
||||
placeholder='e.g., My Workflow Tools'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<label
|
||||
htmlFor='mcp-server-description'
|
||||
className='font-medium text-[13px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Description (optional)
|
||||
</label>
|
||||
<EmcnInput
|
||||
id='mcp-server-description'
|
||||
placeholder='Describe what this server provides...'
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
|
||||
<Button variant='ghost' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Creating...' : 'Create Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error ? error.message : 'Failed to load servers'}
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<ServerSkeleton />
|
||||
<ServerSkeleton />
|
||||
</div>
|
||||
) : showEmptyState ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No workflow MCP servers yet.
|
||||
<br />
|
||||
Create one to expose your workflows as MCP tools.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{filteredServers.map((server) => (
|
||||
<ServerListItem
|
||||
key={server.id}
|
||||
server={server}
|
||||
onViewDetails={() => setSelectedServerId(server.id)}
|
||||
onDelete={() => setServerToDelete(server)}
|
||||
isDeleting={deletingServers.has(server.id)}
|
||||
/>
|
||||
))}
|
||||
{showNoResults && (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No servers found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Delete MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{serverToDelete?.name}</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove all tools and cannot be undone.
|
||||
</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setServerToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDeleteServer}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Files, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
|
||||
import { Files, LogIn, Settings, User, Users, Wrench } from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
Connections,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
SSO,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
WorkflowMcpServers,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
|
||||
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
|
||||
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
@@ -70,7 +69,6 @@ type SettingsSection =
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'workflow-mcp-servers'
|
||||
|
||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
|
||||
|
||||
@@ -114,7 +112,6 @@ const allNavigationItems: NavigationItem[] = [
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
||||
{ id: 'mcp', label: 'MCPs', icon: McpIcon, section: 'tools' },
|
||||
{ id: 'workflow-mcp-servers', label: 'Workflow MCP Servers', icon: Server, section: 'tools' },
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
{
|
||||
@@ -462,7 +459,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -14,7 +14,7 @@ export type WorkflowExecutionPayload = {
|
||||
workflowId: string
|
||||
userId: string
|
||||
input?: any
|
||||
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import type { BlockConfig } from '@/blocks/types'
|
||||
interface ConditionBlockOutput {
|
||||
success: boolean
|
||||
output: {
|
||||
content: string
|
||||
conditionResult: boolean
|
||||
selectedPath: {
|
||||
blockId: string
|
||||
blockType: string
|
||||
blockTitle: string
|
||||
}
|
||||
selectedConditionId: string
|
||||
selectedOption: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +39,8 @@ export const ConditionBlock: BlockConfig<ConditionBlockOutput> = {
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Condition evaluation content' },
|
||||
conditionResult: { type: 'boolean', description: 'Condition result' },
|
||||
selectedPath: { type: 'json', description: 'Selected execution path' },
|
||||
selectedConditionId: { type: 'string', description: 'Selected condition identifier' },
|
||||
selectedOption: { type: 'string', description: 'Selected condition option ID' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
{ label: 'Delete Issue Link', id: 'delete_link' },
|
||||
{ label: 'Add Watcher', id: 'add_watcher' },
|
||||
{ label: 'Remove Watcher', id: 'remove_watcher' },
|
||||
{ label: 'Get Users', id: 'get_users' },
|
||||
],
|
||||
value: () => 'read',
|
||||
},
|
||||
@@ -194,6 +195,71 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: ['update', 'write'] },
|
||||
},
|
||||
// Write Issue additional fields
|
||||
{
|
||||
id: 'assignee',
|
||||
title: 'Assignee Account ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Assignee account ID (e.g., 5b109f2e9729b51b54dc274d)',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
title: 'Priority',
|
||||
type: 'short-input',
|
||||
placeholder: 'Priority ID or name (e.g., "10000" or "High")',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
title: 'Labels',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated labels (e.g., bug, urgent)',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
{
|
||||
id: 'duedate',
|
||||
title: 'Due Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM-DD (e.g., 2024-12-31)',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
{
|
||||
id: 'reporter',
|
||||
title: 'Reporter Account ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Reporter account ID',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
{
|
||||
id: 'environment',
|
||||
title: 'Environment',
|
||||
type: 'long-input',
|
||||
placeholder: 'Environment information (e.g., Production, Staging)',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
{
|
||||
id: 'customFieldId',
|
||||
title: 'Custom Field ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., customfield_10001 or 10001',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
{
|
||||
id: 'teamUuid',
|
||||
title: 'Team UUID',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., b3aa307a-76ea-462d-b6f1-a6e89ce9858a',
|
||||
dependsOn: ['projectId'],
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
// Delete Issue fields
|
||||
{
|
||||
id: 'deleteSubtasks',
|
||||
@@ -351,6 +417,28 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
placeholder: 'Enter link ID to delete',
|
||||
condition: { field: 'operation', value: 'delete_link' },
|
||||
},
|
||||
// Get Users fields
|
||||
{
|
||||
id: 'userAccountId',
|
||||
title: 'Account ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter account ID for specific user',
|
||||
condition: { field: 'operation', value: 'get_users' },
|
||||
},
|
||||
{
|
||||
id: 'usersStartAt',
|
||||
title: 'Start At',
|
||||
type: 'short-input',
|
||||
placeholder: 'Pagination start index (default: 0)',
|
||||
condition: { field: 'operation', value: 'get_users' },
|
||||
},
|
||||
{
|
||||
id: 'usersMaxResults',
|
||||
title: 'Max Results',
|
||||
type: 'short-input',
|
||||
placeholder: 'Maximum users to return (default: 50)',
|
||||
condition: { field: 'operation', value: 'get_users' },
|
||||
},
|
||||
// Trigger SubBlocks
|
||||
...getTrigger('jira_issue_created').subBlocks,
|
||||
...getTrigger('jira_issue_updated').subBlocks,
|
||||
@@ -383,6 +471,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
'jira_delete_issue_link',
|
||||
'jira_add_watcher',
|
||||
'jira_remove_watcher',
|
||||
'jira_get_users',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -438,6 +527,8 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
return 'jira_add_watcher'
|
||||
case 'remove_watcher':
|
||||
return 'jira_remove_watcher'
|
||||
case 'get_users':
|
||||
return 'jira_get_users'
|
||||
default:
|
||||
return 'jira_retrieve'
|
||||
}
|
||||
@@ -461,12 +552,29 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
'Project ID is required. Please select a project or enter a project ID manually.'
|
||||
)
|
||||
}
|
||||
// Parse comma-separated strings into arrays
|
||||
const parseCommaSeparated = (value: string | undefined): string[] | undefined => {
|
||||
if (!value || value.trim() === '') return undefined
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
}
|
||||
|
||||
const writeParams = {
|
||||
projectId: effectiveProjectId,
|
||||
summary: params.summary || '',
|
||||
description: params.description || '',
|
||||
issueType: params.issueType || 'Task',
|
||||
parent: params.parentIssue ? { key: params.parentIssue } : undefined,
|
||||
assignee: params.assignee || undefined,
|
||||
priority: params.priority || undefined,
|
||||
labels: parseCommaSeparated(params.labels),
|
||||
duedate: params.duedate || undefined,
|
||||
reporter: params.reporter || undefined,
|
||||
environment: params.environment || undefined,
|
||||
customFieldId: params.customFieldId || undefined,
|
||||
customFieldValue: params.customFieldValue || undefined,
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
@@ -704,6 +812,16 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
accountId: params.accountId,
|
||||
}
|
||||
}
|
||||
case 'get_users': {
|
||||
return {
|
||||
...baseParams,
|
||||
accountId: params.userAccountId || undefined,
|
||||
startAt: params.usersStartAt ? Number.parseInt(params.usersStartAt) : undefined,
|
||||
maxResults: params.usersMaxResults
|
||||
? Number.parseInt(params.usersMaxResults)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return baseParams
|
||||
}
|
||||
@@ -722,6 +840,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
summary: { type: 'string', description: 'Issue summary' },
|
||||
description: { type: 'string', description: 'Issue description' },
|
||||
issueType: { type: 'string', description: 'Issue type' },
|
||||
// Write operation additional inputs
|
||||
assignee: { type: 'string', description: 'Assignee account ID' },
|
||||
priority: { type: 'string', description: 'Priority ID or name' },
|
||||
labels: { type: 'string', description: 'Comma-separated labels for the issue' },
|
||||
duedate: { type: 'string', description: 'Due date in YYYY-MM-DD format' },
|
||||
reporter: { type: 'string', description: 'Reporter account ID' },
|
||||
environment: { type: 'string', description: 'Environment information' },
|
||||
customFieldId: { type: 'string', description: 'Custom field ID (e.g., customfield_10001)' },
|
||||
customFieldValue: { type: 'string', description: 'Value for the custom field' },
|
||||
// Delete operation inputs
|
||||
deleteSubtasks: { type: 'string', description: 'Whether to delete subtasks (true/false)' },
|
||||
// Assign/Watcher operation inputs
|
||||
@@ -758,6 +885,13 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
linkType: { type: 'string', description: 'Type of link (e.g., "Blocks", "Relates")' },
|
||||
linkComment: { type: 'string', description: 'Optional comment for issue link' },
|
||||
linkId: { type: 'string', description: 'Link ID for delete operation' },
|
||||
// Get Users operation inputs
|
||||
userAccountId: {
|
||||
type: 'string',
|
||||
description: 'Account ID for specific user lookup (optional)',
|
||||
},
|
||||
usersStartAt: { type: 'string', description: 'Pagination start index for users' },
|
||||
usersMaxResults: { type: 'string', description: 'Maximum users to return' },
|
||||
},
|
||||
outputs: {
|
||||
// Common outputs across all Jira operations
|
||||
@@ -834,6 +968,12 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
// jira_add_watcher, jira_remove_watcher outputs
|
||||
watcherAccountId: { type: 'string', description: 'Watcher account ID' },
|
||||
|
||||
// jira_get_users outputs
|
||||
users: {
|
||||
type: 'json',
|
||||
description: 'Array of users with accountId, displayName, emailAddress, active status',
|
||||
},
|
||||
|
||||
// jira_bulk_read outputs
|
||||
// Note: bulk_read returns an array in the output field, each item contains:
|
||||
// ts, issueKey, summary, description, status, assignee, created, updated
|
||||
|
||||
@@ -155,15 +155,6 @@ export const ScheduleBlock: BlockConfig = {
|
||||
condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'inputFormat',
|
||||
title: 'Input Format',
|
||||
type: 'input-format',
|
||||
description:
|
||||
'Define input parameters that will be available when the schedule triggers. Use Value to set default values for scheduled executions.',
|
||||
mode: 'trigger',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'scheduleSave',
|
||||
type: 'schedule-save',
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { ServiceNowIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { ServiceNowResponse } from '@/tools/servicenow/types'
|
||||
|
||||
export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
|
||||
type: 'servicenow',
|
||||
name: 'ServiceNow',
|
||||
description: 'Create, read, update, delete, and bulk import ServiceNow records',
|
||||
authMode: AuthMode.OAuth,
|
||||
description: 'Create, read, update, and delete ServiceNow records',
|
||||
longDescription:
|
||||
'Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL.',
|
||||
'Integrate ServiceNow into your workflow. Create, read, update, and delete records in any ServiceNow table including incidents, tasks, change requests, users, and more.',
|
||||
docsLink: 'https://docs.sim.ai/tools/servicenow',
|
||||
category: 'tools',
|
||||
bgColor: '#032D42',
|
||||
@@ -21,12 +19,12 @@ export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Create Record', id: 'create' },
|
||||
{ label: 'Read Records', id: 'read' },
|
||||
{ label: 'Update Record', id: 'update' },
|
||||
{ label: 'Delete Record', id: 'delete' },
|
||||
{ label: 'Create Record', id: 'servicenow_create_record' },
|
||||
{ label: 'Read Records', id: 'servicenow_read_record' },
|
||||
{ label: 'Update Record', id: 'servicenow_update_record' },
|
||||
{ label: 'Delete Record', id: 'servicenow_delete_record' },
|
||||
],
|
||||
value: () => 'read',
|
||||
value: () => 'servicenow_read_record',
|
||||
},
|
||||
// Instance URL
|
||||
{
|
||||
@@ -35,17 +33,26 @@ export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
|
||||
type: 'short-input',
|
||||
placeholder: 'https://instance.service-now.com',
|
||||
required: true,
|
||||
description: 'Your ServiceNow instance URL',
|
||||
description: 'Your ServiceNow instance URL (e.g., https://yourcompany.service-now.com)',
|
||||
},
|
||||
// OAuth Credential
|
||||
// Username
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'ServiceNow Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'servicenow',
|
||||
requiredScopes: ['useraccount'],
|
||||
placeholder: 'Select ServiceNow account',
|
||||
id: 'username',
|
||||
title: 'Username',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your ServiceNow username',
|
||||
required: true,
|
||||
description: 'ServiceNow user with web service access',
|
||||
},
|
||||
// Password
|
||||
{
|
||||
id: 'password',
|
||||
title: 'Password',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your ServiceNow password',
|
||||
password: true,
|
||||
required: true,
|
||||
description: 'Password for the ServiceNow user',
|
||||
},
|
||||
// Table Name
|
||||
{
|
||||
@@ -63,7 +70,7 @@ export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '{\n "short_description": "Issue description",\n "priority": "1"\n}',
|
||||
condition: { field: 'operation', value: 'create' },
|
||||
condition: { field: 'operation', value: 'servicenow_create_record' },
|
||||
required: true,
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
@@ -96,21 +103,21 @@ Output: {"short_description": "Network outage", "description": "Network connecti
|
||||
title: 'Record sys_id',
|
||||
type: 'short-input',
|
||||
placeholder: 'Specific record sys_id (optional)',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
title: 'Record Number',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., INC0010001 (optional)',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Query String',
|
||||
type: 'short-input',
|
||||
placeholder: 'active=true^priority=1',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
description: 'ServiceNow encoded query string',
|
||||
},
|
||||
{
|
||||
@@ -118,14 +125,14 @@ Output: {"short_description": "Network outage", "description": "Network connecti
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '10',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Fields to Return',
|
||||
type: 'short-input',
|
||||
placeholder: 'number,short_description,priority',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
description: 'Comma-separated list of fields',
|
||||
},
|
||||
// Update-specific: sysId and fields
|
||||
@@ -134,7 +141,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti
|
||||
title: 'Record sys_id',
|
||||
type: 'short-input',
|
||||
placeholder: 'Record sys_id to update',
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
condition: { field: 'operation', value: 'servicenow_update_record' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -143,7 +150,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '{\n "state": "2",\n "assigned_to": "user.sys_id"\n}',
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
condition: { field: 'operation', value: 'servicenow_update_record' },
|
||||
required: true,
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
@@ -175,7 +182,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
title: 'Record sys_id',
|
||||
type: 'short-input',
|
||||
placeholder: 'Record sys_id to delete',
|
||||
condition: { field: 'operation', value: 'delete' },
|
||||
condition: { field: 'operation', value: 'servicenow_delete_record' },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
@@ -187,60 +194,26 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
'servicenow_delete_record',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'create':
|
||||
return 'servicenow_create_record'
|
||||
case 'read':
|
||||
return 'servicenow_read_record'
|
||||
case 'update':
|
||||
return 'servicenow_update_record'
|
||||
case 'delete':
|
||||
return 'servicenow_delete_record'
|
||||
default:
|
||||
throw new Error(`Invalid ServiceNow operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
tool: (params) => params.operation,
|
||||
params: (params) => {
|
||||
const { operation, fields, records, credential, ...rest } = params
|
||||
const { operation, fields, ...rest } = params
|
||||
const isCreateOrUpdate =
|
||||
operation === 'servicenow_create_record' || operation === 'servicenow_update_record'
|
||||
|
||||
// Parse JSON fields if provided
|
||||
let parsedFields: Record<string, any> | undefined
|
||||
if (fields && (operation === 'create' || operation === 'update')) {
|
||||
try {
|
||||
parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid JSON in fields: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (fields && isCreateOrUpdate) {
|
||||
const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields
|
||||
return { ...rest, fields: parsedFields }
|
||||
}
|
||||
|
||||
// Validate OAuth credential
|
||||
if (!credential) {
|
||||
throw new Error('ServiceNow account credential is required')
|
||||
}
|
||||
|
||||
// Build params
|
||||
const baseParams: Record<string, any> = {
|
||||
...rest,
|
||||
credential,
|
||||
}
|
||||
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
return {
|
||||
...baseParams,
|
||||
fields: parsedFields,
|
||||
}
|
||||
}
|
||||
return baseParams
|
||||
return rest
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
instanceUrl: { type: 'string', description: 'ServiceNow instance URL' },
|
||||
credential: { type: 'string', description: 'ServiceNow OAuth credential ID' },
|
||||
username: { type: 'string', description: 'ServiceNow username' },
|
||||
password: { type: 'string', description: 'ServiceNow password' },
|
||||
tableName: { type: 'string', description: 'Table name' },
|
||||
sysId: { type: 'string', description: 'Record sys_id' },
|
||||
number: { type: 'string', description: 'Record number' },
|
||||
|
||||
@@ -3387,17 +3387,14 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function ServiceNowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 1570 1403'
|
||||
width='48'
|
||||
height='48'
|
||||
>
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 71.1 63.6'>
|
||||
<path
|
||||
fill='#62d84e'
|
||||
fillRule='evenodd'
|
||||
d='M1228.4 138.9c129.2 88.9 228.9 214.3 286.3 360.2 57.5 145.8 70 305.5 36 458.5S1437.8 1250 1324 1357.9c-13.3 12.9-28.8 23.4-45.8 30.8-17 7.5-35.2 11.9-53.7 12.9-18.5 1.1-37.1-1.1-54.8-6.6-17.7-5.4-34.3-13.9-49.1-25.2-48.2-35.9-101.8-63.8-158.8-82.6-57.1-18.9-116.7-28.5-176.8-28.5s-119.8 9.6-176.8 28.5c-57 18.8-110.7 46.7-158.9 82.6-14.6 11.2-31 19.8-48.6 25.3s-36 7.8-54.4 6.8c-18.4-.9-36.5-5.1-53.4-12.4s-32.4-17.5-45.8-30.2C132.5 1251 53 1110.8 19 956.8s-20.9-314.6 37.6-461c58.5-146.5 159.6-272 290.3-360.3S631.8.1 789.6.5c156.8 1.3 309.6 49.6 438.8 138.4m-291.8 1014c48.2-19.2 92-48 128.7-84.6 36.7-36.7 65.5-80.4 84.7-128.6 19.2-48.1 28.4-99.7 27-151.5 0-103.9-41.3-203.5-114.8-277S889 396.4 785 396.4s-203.7 41.3-277.2 114.8S393 684.3 393 788.2c-1.4 51.8 7.8 103.4 27 151.5 19.2 48.2 48 91.9 84.7 128.6 36.7 36.6 80.5 65.4 128.6 84.6 48.2 19.2 99.8 28.4 151.7 27 51.8 1.4 103.4-7.8 151.6-27'
|
||||
clipRule='evenodd'
|
||||
fill='#62D84E'
|
||||
d='M35.8,0C16.1,0,0,15.9,0,35.6c0,9.8,4,19.3,11.2,26c2.5,2.4,6.4,2.6,9.2,0.5c9-6.7,21.4-6.7,30.4,0
|
||||
c2.8,2.1,6.7,1.9,9.2-0.5C74.3,48,74.9,25.4,61.3,11.1C54.7,4.1,45.4,0.1,35.8,0 M35.6,53.5C26,53.8,18,46.2,17.8,36.7
|
||||
c0-0.3,0-0.6,0-0.9c0-9.8,8-17.8,17.8-17.8s17.8,8,17.8,17.8c0.3,9.6-7.3,17.5-16.8,17.8C36.2,53.5,35.9,53.5,35.6,53.5'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "Emir Karabeg",
|
||||
"url": "https://x.com/karabegemir",
|
||||
"xHandle": "karabegemir",
|
||||
"avatarUrl": "/studio/authors/emir.png"
|
||||
"avatarUrl": "/studio/authors/emir.jpg"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "Siddharth",
|
||||
"url": "https://x.com/sidganesan",
|
||||
"xHandle": "sidganesan",
|
||||
"avatarUrl": "/studio/authors/sid.png"
|
||||
"avatarUrl": "/studio/authors/sid.jpg"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "Waleed Latif",
|
||||
"url": "https://x.com/typingwala",
|
||||
"xHandle": "typingwala",
|
||||
"avatarUrl": "/studio/authors/waleed.png"
|
||||
"avatarUrl": "/studio/authors/waleed.jpg"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||

|
||||
|
||||
## Why we’re excited
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@ export const DEFAULTS = {
|
||||
BLOCK_TITLE: 'Untitled Block',
|
||||
WORKFLOW_NAME: 'Workflow',
|
||||
MAX_LOOP_ITERATIONS: 1000,
|
||||
MAX_FOREACH_ITEMS: 1000,
|
||||
MAX_PARALLEL_BRANCHES: 20,
|
||||
MAX_WORKFLOW_DEPTH: 10,
|
||||
EXECUTION_TIME: 0,
|
||||
TOKENS: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LoopConstructor } from '@/executor/dag/construction/loops'
|
||||
import { NodeConstructor } from '@/executor/dag/construction/nodes'
|
||||
import { PathConstructor } from '@/executor/dag/construction/paths'
|
||||
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
|
||||
import { buildSentinelStartId, extractBaseBlockId } from '@/executor/utils/subflow-utils'
|
||||
import type {
|
||||
SerializedBlock,
|
||||
SerializedLoop,
|
||||
@@ -79,6 +80,9 @@ export class DAGBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate loop and parallel structure
|
||||
this.validateSubflowStructure(dag)
|
||||
|
||||
logger.info('DAG built', {
|
||||
totalNodes: dag.nodes.size,
|
||||
loopCount: dag.loopConfigs.size,
|
||||
@@ -105,4 +109,43 @@ export class DAGBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that loops and parallels have proper internal structure.
|
||||
* Throws an error if a loop/parallel has no blocks inside or no connections from start.
|
||||
*/
|
||||
private validateSubflowStructure(dag: DAG): void {
|
||||
for (const [id, config] of dag.loopConfigs) {
|
||||
this.validateSubflow(dag, id, config.nodes, 'Loop')
|
||||
}
|
||||
for (const [id, config] of dag.parallelConfigs) {
|
||||
this.validateSubflow(dag, id, config.nodes, 'Parallel')
|
||||
}
|
||||
}
|
||||
|
||||
private validateSubflow(
|
||||
dag: DAG,
|
||||
id: string,
|
||||
nodes: string[] | undefined,
|
||||
type: 'Loop' | 'Parallel'
|
||||
): void {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
throw new Error(
|
||||
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
|
||||
)
|
||||
}
|
||||
|
||||
const sentinelStartNode = dag.nodes.get(buildSentinelStartId(id))
|
||||
if (!sentinelStartNode) return
|
||||
|
||||
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
|
||||
nodes.includes(extractBaseBlockId(edge.target))
|
||||
)
|
||||
|
||||
if (!hasConnections) {
|
||||
throw new Error(
|
||||
`${type} start is not connected to any blocks. Connect a block to the ${type.toLowerCase()} start.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,10 @@ export class DAGExecutor {
|
||||
|
||||
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
|
||||
const loopOrchestrator = new LoopOrchestrator(dag, state, resolver)
|
||||
loopOrchestrator.setContextExtensions(this.contextExtensions)
|
||||
const parallelOrchestrator = new ParallelOrchestrator(dag, state)
|
||||
parallelOrchestrator.setResolver(resolver)
|
||||
parallelOrchestrator.setContextExtensions(this.contextExtensions)
|
||||
const allHandlers = createBlockHandlers()
|
||||
const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface LoopScope {
|
||||
condition?: string
|
||||
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
skipFirstConditionCheck?: boolean
|
||||
/** Error message if loop validation failed (e.g., exceeded max iterations) */
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export interface ParallelScope {
|
||||
@@ -23,6 +25,8 @@ export interface ParallelScope {
|
||||
completedCount: number
|
||||
totalExpectedNodes: number
|
||||
items?: any[]
|
||||
/** Error message if parallel validation failed (e.g., exceeded max branches) */
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export class ExecutionState implements BlockStateController {
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
import '@/executor/__test-utils__/mock-dependencies'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import type { BlockState, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn(() => 'test-request-id'),
|
||||
}))
|
||||
|
||||
vi.mock('@/tools', () => ({
|
||||
executeTool: vi.fn(),
|
||||
}))
|
||||
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const mockExecuteTool = executeTool as ReturnType<typeof vi.fn>
|
||||
|
||||
/**
|
||||
* Simulates what the function_execute tool does when evaluating condition code
|
||||
*/
|
||||
function simulateConditionExecution(code: string): {
|
||||
success: boolean
|
||||
output?: { result: unknown }
|
||||
error?: string
|
||||
} {
|
||||
try {
|
||||
// The code is in format: "const context = {...};\nreturn Boolean(...)"
|
||||
// We need to execute it and return the result
|
||||
const fn = new Function(code)
|
||||
const result = fn()
|
||||
return { success: true, output: { result } }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConditionBlockHandler', () => {
|
||||
let handler: ConditionBlockHandler
|
||||
let mockBlock: SerializedBlock
|
||||
@@ -18,7 +59,6 @@ describe('ConditionBlockHandler', () => {
|
||||
let mockPathTracker: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Define blocks first
|
||||
mockSourceBlock = {
|
||||
id: 'source-block-1',
|
||||
metadata: { id: 'source', name: 'Source Block' },
|
||||
@@ -33,7 +73,7 @@ describe('ConditionBlockHandler', () => {
|
||||
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
|
||||
position: { x: 50, y: 50 },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: { conditions: 'json' }, // Corrected based on previous step
|
||||
inputs: { conditions: 'json' },
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
@@ -56,7 +96,6 @@ describe('ConditionBlockHandler', () => {
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Then define workflow using the block objects
|
||||
mockWorkflow = {
|
||||
blocks: [mockSourceBlock, mockBlock, mockTargetBlock1, mockTargetBlock2],
|
||||
connections: [
|
||||
@@ -84,7 +123,6 @@ describe('ConditionBlockHandler', () => {
|
||||
|
||||
handler = new ConditionBlockHandler(mockPathTracker, mockResolver)
|
||||
|
||||
// Define mock context *after* workflow and blocks are set up
|
||||
mockContext = {
|
||||
workflowId: 'test-workflow-id',
|
||||
blockStates: new Map<string, BlockState>([
|
||||
@@ -99,7 +137,7 @@ describe('ConditionBlockHandler', () => {
|
||||
]),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {}, // Now set the context's env vars
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopExecutions: new Map(),
|
||||
executedBlocks: new Set([mockSourceBlock.id]),
|
||||
@@ -108,11 +146,11 @@ describe('ConditionBlockHandler', () => {
|
||||
completedLoops: new Set(),
|
||||
}
|
||||
|
||||
// Reset mocks using vi
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mock implementations - Removed as it's in the shared mock now
|
||||
// mockResolver.resolveBlockReferences.mockImplementation((value) => value)
|
||||
mockExecuteTool.mockImplementation(async (_toolId: string, params: { code: string }) => {
|
||||
return simulateConditionExecution(params.code)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle condition blocks', () => {
|
||||
@@ -137,11 +175,9 @@ describe('ConditionBlockHandler', () => {
|
||||
blockType: 'target',
|
||||
blockTitle: 'Target Block 1',
|
||||
},
|
||||
selectedConditionId: 'cond1',
|
||||
selectedOption: 'cond1',
|
||||
}
|
||||
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.value > 5')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.value > 5')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.value > 5')
|
||||
@@ -178,11 +214,9 @@ describe('ConditionBlockHandler', () => {
|
||||
blockType: 'target',
|
||||
blockTitle: 'Target Block 2',
|
||||
},
|
||||
selectedConditionId: 'else1',
|
||||
selectedOption: 'else1',
|
||||
}
|
||||
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.value < 0')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.value < 0')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.value < 0')
|
||||
@@ -207,7 +241,7 @@ describe('ConditionBlockHandler', () => {
|
||||
const inputs = { conditions: '{ "invalid json ' }
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
/^Invalid conditions format: Unterminated string.*/
|
||||
/^Invalid conditions format:/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -218,7 +252,6 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('{{source-block-1.value}} > 5')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('10 > 5')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('10 > 5')
|
||||
@@ -245,7 +278,6 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock the full resolution pipeline for variable resolution
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('"john" !== null')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('"john" !== null')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('"john" !== null')
|
||||
@@ -272,7 +304,6 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock the full resolution pipeline for env variable resolution
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('{{POOP}} === "hi"')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('{{POOP}} === "hi"')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('"hi" === "hi"')
|
||||
@@ -300,7 +331,6 @@ describe('ConditionBlockHandler', () => {
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const resolutionError = new Error('Could not resolve reference: invalid-ref')
|
||||
// Mock the pipeline to throw at the variable resolution stage
|
||||
mockResolver.resolveVariableReferences.mockImplementation(() => {
|
||||
throw resolutionError
|
||||
})
|
||||
@@ -317,7 +347,6 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue(
|
||||
'context.nonExistentProperty.doSomething()'
|
||||
)
|
||||
@@ -325,7 +354,7 @@ describe('ConditionBlockHandler', () => {
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()')
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
/^Evaluation error in condition "if": Evaluation error in condition: Cannot read properties of undefined \(reading 'doSomething'\)\. \(Resolved: context\.nonExistentProperty\.doSomething\(\)\)$/
|
||||
/Evaluation error in condition "if".*doSomething/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -333,7 +362,6 @@ describe('ConditionBlockHandler', () => {
|
||||
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Create a new context with empty blockStates instead of trying to delete from readonly map
|
||||
const contextWithoutSource = {
|
||||
...mockContext,
|
||||
blockStates: new Map<string, BlockState>(),
|
||||
@@ -346,7 +374,7 @@ describe('ConditionBlockHandler', () => {
|
||||
const result = await handler.execute(contextWithoutSource, mockBlock, inputs)
|
||||
|
||||
expect(result).toHaveProperty('conditionResult', true)
|
||||
expect(result).toHaveProperty('selectedConditionId', 'cond1')
|
||||
expect(result).toHaveProperty('selectedOption', 'cond1')
|
||||
})
|
||||
|
||||
it('should throw error if target block is missing', async () => {
|
||||
@@ -355,7 +383,6 @@ describe('ConditionBlockHandler', () => {
|
||||
|
||||
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
|
||||
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('true')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('true')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('true')
|
||||
@@ -381,7 +408,6 @@ describe('ConditionBlockHandler', () => {
|
||||
},
|
||||
]
|
||||
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences
|
||||
.mockReturnValueOnce('false')
|
||||
.mockReturnValueOnce('context.value === 99')
|
||||
@@ -394,12 +420,9 @@ describe('ConditionBlockHandler', () => {
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
// Should return success with no path selected (branch ends gracefully)
|
||||
expect((result as any).conditionResult).toBe(false)
|
||||
expect((result as any).selectedPath).toBeNull()
|
||||
expect((result as any).selectedConditionId).toBeNull()
|
||||
expect((result as any).selectedOption).toBeNull()
|
||||
// Decision should not be set when no condition matches
|
||||
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -410,7 +433,6 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Mock the full resolution pipeline
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.item === "apple"')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.item === "apple"')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.item === "apple"')
|
||||
@@ -418,6 +440,6 @@ describe('ConditionBlockHandler', () => {
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
|
||||
expect((result as any).selectedConditionId).toBe('else1')
|
||||
expect((result as any).selectedOption).toBe('else1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const logger = createLogger('ConditionBlockHandler')
|
||||
|
||||
const CONDITION_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Evaluates a single condition expression with variable/block reference resolution
|
||||
* Returns true if condition is met, false otherwise
|
||||
@@ -35,11 +38,38 @@ export async function evaluateConditionExpression(
|
||||
}
|
||||
|
||||
try {
|
||||
const conditionMet = new Function(
|
||||
'context',
|
||||
`with(context) { return ${resolvedConditionValue} }`
|
||||
)(evalContext)
|
||||
return Boolean(conditionMet)
|
||||
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
|
||||
const code = `${contextSetup}\nreturn Boolean(${resolvedConditionValue})`
|
||||
|
||||
const result = await executeTool(
|
||||
'function_execute',
|
||||
{
|
||||
code,
|
||||
timeout: CONDITION_TIMEOUT_MS,
|
||||
envVars: {},
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
},
|
||||
},
|
||||
false,
|
||||
false,
|
||||
ctx
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
logger.error(`Failed to evaluate condition: ${result.error}`, {
|
||||
originalCondition: conditionExpression,
|
||||
resolvedCondition: resolvedConditionValue,
|
||||
evalContext,
|
||||
error: result.error,
|
||||
})
|
||||
throw new Error(
|
||||
`Evaluation error in condition: ${result.error}. (Resolved: ${resolvedConditionValue})`
|
||||
)
|
||||
}
|
||||
|
||||
return Boolean(result.output?.result)
|
||||
} catch (evalError: any) {
|
||||
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
|
||||
originalCondition: conditionExpression,
|
||||
@@ -87,13 +117,11 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
block
|
||||
)
|
||||
|
||||
// Handle case where no condition matched and no else exists - branch ends gracefully
|
||||
if (!selectedConnection || !selectedCondition) {
|
||||
return {
|
||||
...((sourceOutput as any) || {}),
|
||||
conditionResult: false,
|
||||
selectedPath: null,
|
||||
selectedConditionId: null,
|
||||
selectedOption: null,
|
||||
}
|
||||
}
|
||||
@@ -115,7 +143,6 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
|
||||
},
|
||||
selectedOption: selectedCondition.id,
|
||||
selectedConditionId: selectedCondition.id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,14 +233,12 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
if (elseConnection) {
|
||||
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
|
||||
}
|
||||
// Else exists but has no connection - treat as no match, branch ends
|
||||
logger.info(`No condition matched and else has no connection - branch ending`, {
|
||||
blockId: block.id,
|
||||
})
|
||||
return { selectedConnection: null, selectedCondition: null }
|
||||
}
|
||||
|
||||
// No condition matched and no else exists - branch ends gracefully
|
||||
logger.info(`No condition matched and no else block - branch ending`, { blockId: block.id })
|
||||
return { selectedConnection: null, selectedCondition: null }
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { buildLoopIndexCondition, DEFAULTS, EDGE } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import type { LoopScope } from '@/executor/execution/state'
|
||||
import type { BlockStateController } from '@/executor/execution/types'
|
||||
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import type { LoopConfigWithNodes } from '@/executor/types/loop'
|
||||
import { replaceValidReferences } from '@/executor/utils/reference-validation'
|
||||
import {
|
||||
addSubflowErrorLog,
|
||||
buildSentinelEndId,
|
||||
buildSentinelStartId,
|
||||
extractBaseBlockId,
|
||||
resolveArrayInput,
|
||||
validateMaxCount,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedLoop } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('LoopOrchestrator')
|
||||
|
||||
const LOOP_CONDITION_TIMEOUT_MS = 5000
|
||||
|
||||
export type LoopRoute = typeof EDGE.LOOP_CONTINUE | typeof EDGE.LOOP_EXIT
|
||||
|
||||
export interface LoopContinuationResult {
|
||||
@@ -28,6 +35,7 @@ export interface LoopContinuationResult {
|
||||
|
||||
export class LoopOrchestrator {
|
||||
private edgeManager: EdgeManager | null = null
|
||||
private contextExtensions: ContextExtensions | null = null
|
||||
|
||||
constructor(
|
||||
private dag: DAG,
|
||||
@@ -35,6 +43,10 @@ export class LoopOrchestrator {
|
||||
private resolver: VariableResolver
|
||||
) {}
|
||||
|
||||
setContextExtensions(contextExtensions: ContextExtensions): void {
|
||||
this.contextExtensions = contextExtensions
|
||||
}
|
||||
|
||||
setEdgeManager(edgeManager: EdgeManager): void {
|
||||
this.edgeManager = edgeManager
|
||||
}
|
||||
@@ -44,7 +56,6 @@ export class LoopOrchestrator {
|
||||
if (!loopConfig) {
|
||||
throw new Error(`Loop config not found: ${loopId}`)
|
||||
}
|
||||
|
||||
const scope: LoopScope = {
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
@@ -54,15 +65,70 @@ export class LoopOrchestrator {
|
||||
const loopType = loopConfig.loopType
|
||||
|
||||
switch (loopType) {
|
||||
case 'for':
|
||||
case 'for': {
|
||||
scope.loopType = 'for'
|
||||
scope.maxIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
||||
const requestedIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
||||
|
||||
const iterationError = validateMaxCount(
|
||||
requestedIterations,
|
||||
DEFAULTS.MAX_LOOP_ITERATIONS,
|
||||
'For loop iterations'
|
||||
)
|
||||
if (iterationError) {
|
||||
logger.error(iterationError, { loopId, requestedIterations })
|
||||
this.addLoopErrorLog(ctx, loopId, loopType, iterationError, {
|
||||
iterations: requestedIterations,
|
||||
})
|
||||
scope.maxIterations = 0
|
||||
scope.validationError = iterationError
|
||||
scope.condition = buildLoopIndexCondition(0)
|
||||
ctx.loopExecutions?.set(loopId, scope)
|
||||
throw new Error(iterationError)
|
||||
}
|
||||
|
||||
scope.maxIterations = requestedIterations
|
||||
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
||||
break
|
||||
}
|
||||
|
||||
case 'forEach': {
|
||||
scope.loopType = 'forEach'
|
||||
const items = this.resolveForEachItems(ctx, loopConfig.forEachItems)
|
||||
let items: any[]
|
||||
try {
|
||||
items = this.resolveForEachItems(ctx, loopConfig.forEachItems)
|
||||
} catch (error) {
|
||||
const errorMessage = `ForEach loop resolution failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
logger.error(errorMessage, { loopId, forEachItems: loopConfig.forEachItems })
|
||||
this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
|
||||
forEachItems: loopConfig.forEachItems,
|
||||
})
|
||||
scope.items = []
|
||||
scope.maxIterations = 0
|
||||
scope.validationError = errorMessage
|
||||
scope.condition = buildLoopIndexCondition(0)
|
||||
ctx.loopExecutions?.set(loopId, scope)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const sizeError = validateMaxCount(
|
||||
items.length,
|
||||
DEFAULTS.MAX_FOREACH_ITEMS,
|
||||
'ForEach loop collection size'
|
||||
)
|
||||
if (sizeError) {
|
||||
logger.error(sizeError, { loopId, collectionSize: items.length })
|
||||
this.addLoopErrorLog(ctx, loopId, loopType, sizeError, {
|
||||
forEachItems: loopConfig.forEachItems,
|
||||
collectionSize: items.length,
|
||||
})
|
||||
scope.items = []
|
||||
scope.maxIterations = 0
|
||||
scope.validationError = sizeError
|
||||
scope.condition = buildLoopIndexCondition(0)
|
||||
ctx.loopExecutions?.set(loopId, scope)
|
||||
throw new Error(sizeError)
|
||||
}
|
||||
|
||||
scope.items = items
|
||||
scope.maxIterations = items.length
|
||||
scope.item = items[0]
|
||||
@@ -75,15 +141,35 @@ export class LoopOrchestrator {
|
||||
scope.condition = loopConfig.whileCondition
|
||||
break
|
||||
|
||||
case 'doWhile':
|
||||
case 'doWhile': {
|
||||
scope.loopType = 'doWhile'
|
||||
if (loopConfig.doWhileCondition) {
|
||||
scope.condition = loopConfig.doWhileCondition
|
||||
} else {
|
||||
scope.maxIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
||||
const requestedIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
||||
|
||||
const iterationError = validateMaxCount(
|
||||
requestedIterations,
|
||||
DEFAULTS.MAX_LOOP_ITERATIONS,
|
||||
'Do-While loop iterations'
|
||||
)
|
||||
if (iterationError) {
|
||||
logger.error(iterationError, { loopId, requestedIterations })
|
||||
this.addLoopErrorLog(ctx, loopId, loopType, iterationError, {
|
||||
iterations: requestedIterations,
|
||||
})
|
||||
scope.maxIterations = 0
|
||||
scope.validationError = iterationError
|
||||
scope.condition = buildLoopIndexCondition(0)
|
||||
ctx.loopExecutions?.set(loopId, scope)
|
||||
throw new Error(iterationError)
|
||||
}
|
||||
|
||||
scope.maxIterations = requestedIterations
|
||||
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown loop type: ${loopType}`)
|
||||
@@ -96,6 +182,23 @@ export class LoopOrchestrator {
|
||||
return scope
|
||||
}
|
||||
|
||||
private addLoopErrorLog(
|
||||
ctx: ExecutionContext,
|
||||
loopId: string,
|
||||
loopType: string,
|
||||
errorMessage: string,
|
||||
inputData?: any
|
||||
): void {
|
||||
addSubflowErrorLog(
|
||||
ctx,
|
||||
loopId,
|
||||
'loop',
|
||||
errorMessage,
|
||||
{ loopType, ...inputData },
|
||||
this.contextExtensions
|
||||
)
|
||||
}
|
||||
|
||||
storeLoopNodeOutput(
|
||||
ctx: ExecutionContext,
|
||||
loopId: string,
|
||||
@@ -112,7 +215,10 @@ export class LoopOrchestrator {
|
||||
scope.currentIterationOutputs.set(baseId, output)
|
||||
}
|
||||
|
||||
evaluateLoopContinuation(ctx: ExecutionContext, loopId: string): LoopContinuationResult {
|
||||
async evaluateLoopContinuation(
|
||||
ctx: ExecutionContext,
|
||||
loopId: string
|
||||
): Promise<LoopContinuationResult> {
|
||||
const scope = ctx.loopExecutions?.get(loopId)
|
||||
if (!scope) {
|
||||
logger.error('Loop scope not found during continuation evaluation', { loopId })
|
||||
@@ -123,7 +229,6 @@ export class LoopOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cancellation
|
||||
if (ctx.isCancelled) {
|
||||
logger.info('Loop execution cancelled', { loopId, iteration: scope.iteration })
|
||||
return this.createExitResult(ctx, loopId, scope)
|
||||
@@ -140,7 +245,7 @@ export class LoopOrchestrator {
|
||||
|
||||
scope.currentIterationOutputs.clear()
|
||||
|
||||
if (!this.evaluateCondition(ctx, scope, scope.iteration + 1)) {
|
||||
if (!(await this.evaluateCondition(ctx, scope, scope.iteration + 1))) {
|
||||
return this.createExitResult(ctx, loopId, scope)
|
||||
}
|
||||
|
||||
@@ -173,7 +278,11 @@ export class LoopOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateCondition(ctx: ExecutionContext, scope: LoopScope, iteration?: number): boolean {
|
||||
private async evaluateCondition(
|
||||
ctx: ExecutionContext,
|
||||
scope: LoopScope,
|
||||
iteration?: number
|
||||
): Promise<boolean> {
|
||||
if (!scope.condition) {
|
||||
logger.warn('No condition defined for loop')
|
||||
return false
|
||||
@@ -184,7 +293,7 @@ export class LoopOrchestrator {
|
||||
scope.iteration = iteration
|
||||
}
|
||||
|
||||
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
|
||||
if (iteration !== undefined) {
|
||||
scope.iteration = currentIteration
|
||||
@@ -223,7 +332,6 @@ export class LoopOrchestrator {
|
||||
const loopNodes = loopConfig.nodes
|
||||
const allLoopNodeIds = new Set([sentinelStartId, sentinelEndId, ...loopNodes])
|
||||
|
||||
// Clear deactivated edges for loop nodes so error/success edges can be re-evaluated
|
||||
if (this.edgeManager) {
|
||||
this.edgeManager.clearDeactivatedEdgesForNodes(allLoopNodeIds)
|
||||
}
|
||||
@@ -263,7 +371,7 @@ export class LoopOrchestrator {
|
||||
*
|
||||
* @returns true if the loop should execute, false if it should be skipped
|
||||
*/
|
||||
evaluateInitialCondition(ctx: ExecutionContext, loopId: string): boolean {
|
||||
async evaluateInitialCondition(ctx: ExecutionContext, loopId: string): Promise<boolean> {
|
||||
const scope = ctx.loopExecutions?.get(loopId)
|
||||
if (!scope) {
|
||||
logger.warn('Loop scope not found for initial condition evaluation', { loopId })
|
||||
@@ -300,7 +408,7 @@ export class LoopOrchestrator {
|
||||
return false
|
||||
}
|
||||
|
||||
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
logger.info('While loop initial condition evaluation', {
|
||||
loopId,
|
||||
condition: scope.condition,
|
||||
@@ -327,11 +435,11 @@ export class LoopOrchestrator {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private evaluateWhileCondition(
|
||||
private async evaluateWhileCondition(
|
||||
ctx: ExecutionContext,
|
||||
condition: string,
|
||||
scope: LoopScope
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
if (!condition) {
|
||||
return false
|
||||
}
|
||||
@@ -343,7 +451,6 @@ export class LoopOrchestrator {
|
||||
workflowVariables: ctx.workflowVariables,
|
||||
})
|
||||
|
||||
// Use generic utility for smart variable reference replacement
|
||||
const evaluatedCondition = replaceValidReferences(condition, (match) => {
|
||||
const resolved = this.resolver.resolveSingleReference(ctx, '', match, scope)
|
||||
logger.info('Resolved variable reference in loop condition', {
|
||||
@@ -352,11 +459,9 @@ export class LoopOrchestrator {
|
||||
resolvedType: typeof resolved,
|
||||
})
|
||||
if (resolved !== undefined) {
|
||||
// For booleans and numbers, return as-is (no quotes)
|
||||
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
|
||||
return String(resolved)
|
||||
}
|
||||
// For strings that represent booleans, return without quotes
|
||||
if (typeof resolved === 'string') {
|
||||
const lower = resolved.toLowerCase().trim()
|
||||
if (lower === 'true' || lower === 'false') {
|
||||
@@ -364,13 +469,33 @@ export class LoopOrchestrator {
|
||||
}
|
||||
return `"${resolved}"`
|
||||
}
|
||||
// For other types, stringify them
|
||||
return JSON.stringify(resolved)
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
const result = Boolean(new Function(`return (${evaluatedCondition})`)())
|
||||
const requestId = generateRequestId()
|
||||
const code = `return Boolean(${evaluatedCondition})`
|
||||
|
||||
const vmResult = await executeInIsolatedVM({
|
||||
code,
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: LOOP_CONDITION_TIMEOUT_MS,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (vmResult.error) {
|
||||
logger.error('Failed to evaluate loop condition', {
|
||||
condition,
|
||||
evaluatedCondition,
|
||||
error: vmResult.error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const result = Boolean(vmResult.result)
|
||||
|
||||
logger.info('Loop condition evaluation result', {
|
||||
originalCondition: condition,
|
||||
@@ -386,54 +511,6 @@ export class LoopOrchestrator {
|
||||
}
|
||||
|
||||
private resolveForEachItems(ctx: ExecutionContext, items: any): any[] {
|
||||
if (Array.isArray(items)) {
|
||||
return items
|
||||
}
|
||||
|
||||
if (typeof items === 'object' && items !== null) {
|
||||
return Object.entries(items)
|
||||
}
|
||||
|
||||
if (typeof items === 'string') {
|
||||
if (items.startsWith('<') && items.endsWith('>')) {
|
||||
const resolved = this.resolver.resolveSingleReference(ctx, '', items)
|
||||
if (Array.isArray(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = items.replace(/'/g, '"')
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse forEach items', { items, error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = this.resolver.resolveInputs(ctx, 'loop_foreach_items', { items }).items
|
||||
|
||||
if (Array.isArray(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
|
||||
logger.warn('ForEach items did not resolve to array', {
|
||||
items,
|
||||
resolved,
|
||||
})
|
||||
|
||||
return []
|
||||
} catch (error: any) {
|
||||
logger.error('Error resolving forEach items, returning empty array:', {
|
||||
error: error.message,
|
||||
})
|
||||
return []
|
||||
}
|
||||
return resolveArrayInput(ctx, items, this.resolver)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user