diff --git a/.env.dev.example b/.env.dev.example index df55a51ae30a..92099fe00f25 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -156,3 +156,7 @@ LANGFUSE_ENABLE_EVENTS_TABLE_V2_APIS=true LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE=true CLICKHOUSE_USE_LIGHTWEIGHT_UPDATE="true" + +# Workflow Tool: Standards Search API (Python service) +WORKFLOW_TOOL_SEARCH_API_URL=http://localhost:8000 +WORKFLOW_TOOL_SEARCH_API_KEY=your_search_api_key diff --git a/.gitignore b/.gitignore index a888924343cb..ab60d4d8ddba 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ web/test-results/* # Refactoring planning files (local only) **/.refactor/ + +# OMC state files +.omc/ diff --git a/packages/shared/prisma/generated/types.ts b/packages/shared/prisma/generated/types.ts index d47a64dfae79..463df6520a1c 100644 --- a/packages/shared/prisma/generated/types.ts +++ b/packages/shared/prisma/generated/types.ts @@ -947,6 +947,22 @@ export type VerificationToken = { token: string; expires: Timestamp; }; +export type Workflow = { + id: string; + created_at: Generated; + updated_at: Generated; + project_id: string; + created_by: string; + name: string; + description: Generated; + version: number; + tags: Generated; + definition: unknown; + input_schema: unknown | null; + last_execution_at: Timestamp | null; + last_execution_results: unknown | null; + last_execution_status: string | null; +}; export type DB = { Account: Account; actions: Action; @@ -1011,4 +1027,5 @@ export type DB = { triggers: Trigger; users: User; verification_tokens: VerificationToken; + workflows: Workflow; }; diff --git a/packages/shared/prisma/migrations/20260307003444_add_workflows/migration.sql b/packages/shared/prisma/migrations/20260307003444_add_workflows/migration.sql new file mode 100644 index 000000000000..2de35e9aea2b --- /dev/null +++ b/packages/shared/prisma/migrations/20260307003444_add_workflows/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "workflows" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "project_id" TEXT NOT NULL, + "created_by" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "version" INTEGER NOT NULL, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "definition" JSONB NOT NULL, + "input_schema" JSONB, + + CONSTRAINT "workflows_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "workflows_project_id_name_version_key" ON "workflows"("project_id", "name", "version"); + +-- CreateIndex +CREATE INDEX "workflows_project_id_id_idx" ON "workflows"("project_id", "id"); + +-- CreateIndex +CREATE INDEX "workflows_created_at_idx" ON "workflows"("created_at"); + +-- CreateIndex +CREATE INDEX "workflows_updated_at_idx" ON "workflows"("updated_at"); + +-- CreateIndex +CREATE INDEX "workflows_tags_idx" ON "workflows" USING GIN ("tags"); + +-- AddForeignKey +ALTER TABLE "workflows" ADD CONSTRAINT "workflows_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/shared/prisma/migrations/20260307171547_add_workflow_execution_history/migration.sql b/packages/shared/prisma/migrations/20260307171547_add_workflow_execution_history/migration.sql new file mode 100644 index 000000000000..269f0b61f8ef --- /dev/null +++ b/packages/shared/prisma/migrations/20260307171547_add_workflow_execution_history/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "workflows" ADD COLUMN "last_execution_at" TIMESTAMP(3), +ADD COLUMN "last_execution_results" JSONB, +ADD COLUMN "last_execution_status" TEXT; diff --git a/packages/shared/prisma/schema.prisma b/packages/shared/prisma/schema.prisma index 8809e0543d50..cae46d3b4d20 100644 --- a/packages/shared/prisma/schema.prisma +++ b/packages/shared/prisma/schema.prisma @@ -162,6 +162,7 @@ model Project { PromptDependency PromptDependency[] LlmSchema LlmSchema[] LlmTool LlmTool[] + Workflow Workflow[] PromptProtectedLabels PromptProtectedLabels[] Dashboard Dashboard[] DashboardWidget DashboardWidget[] @@ -809,6 +810,38 @@ model PromptDependency { @@map("prompt_dependencies") } +model Workflow { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + projectId String @map("project_id") + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + createdBy String @map("created_by") + + name String + description String @default("") + version Int + tags String[] @default([]) + + // Entire graph stored as JSON: { nodes: WorkflowNodeDef[], edges: WorkflowEdgeDef[] } + definition Json @db.Json + + inputSchema Json? @map("input_schema") @db.Json + + lastExecutionAt DateTime? @map("last_execution_at") + lastExecutionResults Json? @map("last_execution_results") @db.Json + lastExecutionStatus String? @map("last_execution_status") + + @@unique([projectId, name, version]) + @@index([projectId, id]) + @@index([createdAt]) + @@index([updatedAt]) + @@index([tags(ops: ArrayOps)], type: Gin) + @@map("workflows") +} + model PromptProtectedLabels { id String @id @default(cuid()) createdAt DateTime @default(now()) @map("created_at") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e408f24f524..18f936ddc5f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,7 +60,7 @@ importers: version: 15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-auth: specifier: ^4.24.13 - version: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) zod: specifier: ^3.25.62 version: 3.25.62 @@ -261,7 +261,7 @@ importers: version: 4.1.1 next-auth: specifier: ^4.24.13 - version: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nodemailer: specifier: ^7.0.11 version: 7.0.11 @@ -584,6 +584,9 @@ importers: '@uiw/react-codemirror': specifier: ^4.25.4 version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.8)(codemirror@6.0.1(@lezer/common@1.5.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@xyflow/react': + specifier: ^12.10.1 + version: 12.10.1(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ai: specifier: ^3.4.9 version: 3.4.9(openai@4.104.0(zod@3.25.62))(react@19.2.3)(solid-js@1.8.18)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.27(typescript@5.9.2))(zod@3.25.62) @@ -665,12 +668,15 @@ importers: nanoid: specifier: ^3.3.8 version: 3.3.11 + neo4j-driver: + specifier: ^6.0.1 + version: 6.0.1 next: specifier: 15.5.10 version: 15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-auth: specifier: ^4.24.13 - version: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-query-params: specifier: ^5.1.0 version: 5.1.0(next@15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(use-query-params@2.2.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -5873,6 +5879,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -5885,6 +5894,9 @@ packages: '@types/d3-scale@4.0.8': resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.6': resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} @@ -5894,6 +5906,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -6449,6 +6467,15 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.10.1': + resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.75': + resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -6780,6 +6807,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade bcryptjs@2.4.3: resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} @@ -6848,6 +6876,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -7019,6 +7050,9 @@ packages: class-variance-authority@0.7.0: resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -7270,6 +7304,14 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -7290,6 +7332,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -7306,6 +7352,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -9936,6 +9992,16 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neo4j-driver-bolt-connection@6.0.1: + resolution: {integrity: sha512-1KyG73TO+CwnYJisdHD0sjUw9yR+P5q3JFcmVPzsHT4/whzCjuXSMpmY4jZcHH2PdY2cBUq4l/6WcDiPMxW2UA==} + + neo4j-driver-core@6.0.1: + resolution: {integrity: sha512-5I2KxICAvcHxnWdJyDqwu8PBAQvWVTlQH2ve3VQmtVdJScPqWhpXN1PiX5IIl+cRF3pFpz9GQF53B5n6s0QQUQ==} + + neo4j-driver@6.0.1: + resolution: {integrity: sha512-8DDF2MwEJNz7y7cp97x4u8fmVIP4CWS8qNBxdwxTG0fWtsS+2NdeC+7uXwmmuFOpHvkfXqv63uWY73bfDtOH8Q==} + engines: {node: '>=18.0.0'} + netmask@2.0.2: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} @@ -12586,6 +12652,21 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -13137,7 +13218,7 @@ snapshots: '@smithy/util-middleware': 3.0.7 '@smithy/util-retry': 3.0.7 '@smithy/util-utf8': 3.0.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -13341,7 +13422,7 @@ snapshots: '@smithy/types': 3.5.0 '@smithy/util-middleware': 3.0.7 fast-xml-parser: 4.4.1 - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/core@3.825.0': dependencies: @@ -13399,7 +13480,7 @@ snapshots: '@aws-sdk/types': 3.667.0 '@smithy/property-provider': 3.1.7 '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.825.0': dependencies: @@ -13436,7 +13517,7 @@ snapshots: '@smithy/smithy-client': 3.4.0 '@smithy/types': 3.5.0 '@smithy/util-stream': 3.1.9 - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.825.0': dependencies: @@ -13491,7 +13572,7 @@ snapshots: '@smithy/property-provider': 3.1.7 '@smithy/shared-ini-file-loader': 3.1.8 '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - aws-crt @@ -13655,7 +13736,7 @@ snapshots: '@smithy/property-provider': 3.1.7 '@smithy/shared-ini-file-loader': 3.1.8 '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.825.0': dependencies: @@ -13693,7 +13774,7 @@ snapshots: '@smithy/property-provider': 3.1.7 '@smithy/shared-ini-file-loader': 3.1.8 '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - aws-crt @@ -13744,7 +13825,7 @@ snapshots: '@aws-sdk/types': 3.667.0 '@smithy/property-provider': 3.1.7 '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/credential-provider-web-identity@3.825.0': dependencies: @@ -13957,7 +14038,7 @@ snapshots: '@smithy/util-middleware': 3.0.7 '@smithy/util-stream': 3.1.9 '@smithy/util-utf8': 3.0.0 - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/middleware-sdk-s3@3.940.0': dependencies: @@ -14243,7 +14324,7 @@ snapshots: '@smithy/property-provider': 3.1.7 '@smithy/shared-ini-file-loader': 3.1.8 '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/token-providers@3.825.0': dependencies: @@ -14317,7 +14398,7 @@ snapshots: '@aws-sdk/util-arn-parser@3.679.0': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 '@aws-sdk/util-arn-parser@3.893.0': dependencies: @@ -16336,7 +16417,7 @@ snapshots: '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.17.1(prisma@6.17.1(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': dependencies: '@prisma/client': 6.17.1(prisma@6.17.1(typescript@5.9.2))(typescript@5.9.2) - next-auth: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next-auth: 4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@next/bundle-analyzer@15.5.10': dependencies: @@ -18460,7 +18541,7 @@ snapshots: '@smithy/property-provider': 3.1.7 '@smithy/types': 3.5.0 '@smithy/url-parser': 3.0.7 - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/credential-provider-imds@4.2.5': dependencies: @@ -18647,7 +18728,7 @@ snapshots: '@smithy/is-array-buffer@3.0.0': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/is-array-buffer@4.2.0': dependencies: @@ -18837,7 +18918,7 @@ snapshots: '@smithy/property-provider@3.1.7': dependencies: '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/property-provider@4.2.5': dependencies: @@ -18868,7 +18949,7 @@ snapshots: dependencies: '@smithy/types': 3.5.0 '@smithy/util-uri-escape': 3.0.0 - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/querystring-builder@4.2.5': dependencies: @@ -18885,7 +18966,7 @@ snapshots: '@smithy/querystring-parser@3.0.7': dependencies: '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/querystring-parser@4.2.5': dependencies: @@ -18912,7 +18993,7 @@ snapshots: '@smithy/shared-ini-file-loader@3.1.8': dependencies: '@smithy/types': 3.5.0 - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/shared-ini-file-loader@4.4.0': dependencies: @@ -18933,7 +19014,7 @@ snapshots: '@smithy/util-middleware': 3.0.7 '@smithy/util-uri-escape': 3.0.0 '@smithy/util-utf8': 3.0.0 - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/signature-v4@5.3.5': dependencies: @@ -19060,7 +19141,7 @@ snapshots: '@smithy/util-buffer-from@3.0.0': dependencies: '@smithy/is-array-buffer': 3.0.0 - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/util-buffer-from@4.2.0': dependencies: @@ -19069,7 +19150,7 @@ snapshots: '@smithy/util-config-provider@3.0.0': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/util-config-provider@4.2.0': dependencies: @@ -19221,7 +19302,7 @@ snapshots: '@smithy/util-uri-escape@3.0.0': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 '@smithy/util-uri-escape@4.2.0': dependencies: @@ -19460,6 +19541,10 @@ snapshots: '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-interpolate@3.0.4': @@ -19472,6 +19557,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.3 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.6': dependencies: '@types/d3-path': 3.1.0 @@ -19480,6 +19567,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -20133,6 +20229,29 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.10.1(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@xyflow/system': 0.0.75 + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.3)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.75': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -20605,6 +20724,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + builtin-modules@3.3.0: {} builtins@5.1.0: @@ -20804,6 +20928,8 @@ snapshots: dependencies: clsx: 2.0.0 + classcat@5.0.5: {} + clean-stack@2.2.0: {} cli-cursor@5.0.0: @@ -21043,6 +21169,13 @@ snapshots: d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} d3-format@3.1.0: {} @@ -21061,6 +21194,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -21075,6 +21210,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@4.0.1: {} @@ -21582,8 +21734,8 @@ snapshots: '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@2.6.1)) @@ -21619,21 +21771,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - get-tsconfig: 4.13.0 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -21648,18 +21785,6 @@ snapshots: eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - optional: true - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2) - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -21679,35 +21804,6 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -24428,6 +24524,20 @@ snapshots: neo-async@2.6.2: {} + neo4j-driver-bolt-connection@6.0.1: + dependencies: + buffer: 6.0.3 + neo4j-driver-core: 6.0.1 + string_decoder: 1.3.0 + + neo4j-driver-core@6.0.1: {} + + neo4j-driver@6.0.1: + dependencies: + neo4j-driver-bolt-connection: 6.0.1 + neo4j-driver-core: 6.0.1 + rxjs: 7.8.2 + netmask@2.0.2: {} new-github-issue-url@0.2.1: {} @@ -24436,7 +24546,7 @@ snapshots: dependencies: type-fest: 2.19.0 - next-auth@4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next-auth@4.24.13(patch_hash=g7rpu2soo4na5splvh6zgnobma)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.28.4 '@panva/hkdf': 1.2.1 @@ -27312,4 +27422,11 @@ snapshots: zod@3.25.76: {} + zustand@4.5.7(@types/react@19.2.3)(react@19.2.3): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.3 + react: 19.2.3 + zwitch@2.0.4: {} diff --git a/web/package.json b/web/package.json index 8c613093dca6..b82ebdf792df 100644 --- a/web/package.json +++ b/web/package.json @@ -102,6 +102,7 @@ "@trpc/server": "^11.8.0", "@uiw/codemirror-themes": "^4.23.7", "@uiw/react-codemirror": "^4.25.4", + "@xyflow/react": "^12.10.1", "ai": "^3.4.9", "bcryptjs": "^2.4.3", "bullmq": "^5.34.10", @@ -129,6 +130,7 @@ "lodash": "^4.17.23", "lucide-react": "^0.552.0", "nanoid": "^3.3.8", + "neo4j-driver": "^6.0.1", "next": "15.5.10", "next-auth": "^4.24.13", "next-query-params": "^5.1.0", diff --git a/web/src/components/ChatMessages/ChatMessageComponent.tsx b/web/src/components/ChatMessages/ChatMessageComponent.tsx index f691ded816cb..b5d5f692fb87 100644 --- a/web/src/components/ChatMessages/ChatMessageComponent.tsx +++ b/web/src/components/ChatMessages/ChatMessageComponent.tsx @@ -309,6 +309,8 @@ const MemoizedEditor = memo(function MemoizedEditor(props: { editable={true} lineNumbers={false} placeholder={placeholder} + minHeight="150px" + maxHeight="500px" /> ); }); diff --git a/web/src/components/layouts/routes.tsx b/web/src/components/layouts/routes.tsx index a96a833d77b0..bda2984e630f 100644 --- a/web/src/components/layouts/routes.tsx +++ b/web/src/components/layouts/routes.tsx @@ -19,6 +19,7 @@ import { ClipboardPen, Clock, Beaker, + GitBranch, } from "lucide-react"; import { type ReactNode } from "react"; import { type Entitlement } from "@/src/features/entitlements/constants/entitlements"; @@ -139,6 +140,14 @@ export const ROUTES: Route[] = [ group: RouteGroup.PromptManagement, section: RouteSection.Main, }, + { + title: "Workflows", + pathname: "/project/[projectId]/workflows", + icon: GitBranch, + productModule: "playground", + group: RouteGroup.PromptManagement, + section: RouteSection.Main, + }, { title: "Scores", pathname: `/project/[projectId]/scores`, diff --git a/web/src/features/audit-logs/auditLog.ts b/web/src/features/audit-logs/auditLog.ts index 3802d71cb6ff..e6bfa88cc8d0 100644 --- a/web/src/features/audit-logs/auditLog.ts +++ b/web/src/features/audit-logs/auditLog.ts @@ -35,6 +35,7 @@ export type AuditableResource = | "llmApiKey" | "llmTool" | "llmSchema" + | "workflow" | "batchExport" | "stripeCheckoutSession" | "batchAction" diff --git a/web/src/features/mcp/features/workflows/index.ts b/web/src/features/mcp/features/workflows/index.ts new file mode 100644 index 000000000000..aeec68f0cf22 --- /dev/null +++ b/web/src/features/mcp/features/workflows/index.ts @@ -0,0 +1,115 @@ +/** + * Workflows MCP Feature Module + * + * Provides tools for managing Langfuse workflows via the MCP protocol. + * This module exports all workflow-related tools for registration with the MCP server. + * + * Tools provided: + * - listWorkflows: List and filter workflows with pagination (read-only) + * - getWorkflow: Fetch a specific workflow by ID (read-only) + * - createWorkflow: Create a new workflow (destructive) + * - updateWorkflow: Update workflow metadata (destructive) + * - deleteWorkflow: Delete a workflow (destructive) + * - updateNodePrompt: Update the prompt assigned to a workflow node (destructive) + * - importPromptToNode: Import a registered Langfuse prompt into a workflow node (destructive) + * - addNode: Add a node to a workflow (destructive) + * - removeNode: Remove a node from a workflow (destructive) + * - connectNodes: Connect two nodes in a workflow (destructive) + * - disconnectNodes: Disconnect two nodes in a workflow (destructive) + */ + +import type { McpFeatureModule } from "../../server/registry"; +import { listWorkflowsTool, handleListWorkflows } from "./tools/listWorkflows"; +import { getWorkflowTool, handleGetWorkflow } from "./tools/getWorkflow"; +import { + createWorkflowTool, + handleCreateWorkflow, +} from "./tools/createWorkflow"; +import { + updateWorkflowTool, + handleUpdateWorkflow, +} from "./tools/updateWorkflow"; +import { + deleteWorkflowTool, + handleDeleteWorkflow, +} from "./tools/deleteWorkflow"; +import { + updateNodePromptTool, + handleUpdateNodePrompt, +} from "./tools/updateNodePrompt"; +import { + importPromptToNodeTool, + handleImportPromptToNode, +} from "./tools/importPromptToNode"; +import { addNodeTool, handleAddNode } from "./tools/addNode"; +import { removeNodeTool, handleRemoveNode } from "./tools/removeNode"; +import { connectNodesTool, handleConnectNodes } from "./tools/connectNodes"; +import { + disconnectNodesTool, + handleDisconnectNodes, +} from "./tools/disconnectNodes"; + +/** + * Workflows Feature Module + * + * Registers all workflow management tools with the MCP server. + * Tools are automatically available to MCP clients once registered. + */ +export const workflowsFeature: McpFeatureModule = { + name: "workflows", + description: + "Manage Langfuse workflows - create, retrieve, update, and orchestrate workflow nodes", + + tools: [ + { + definition: listWorkflowsTool, + handler: handleListWorkflows, + }, + { + definition: getWorkflowTool, + handler: handleGetWorkflow, + }, + { + definition: createWorkflowTool, + handler: handleCreateWorkflow, + }, + { + definition: updateWorkflowTool, + handler: handleUpdateWorkflow, + }, + { + definition: deleteWorkflowTool, + handler: handleDeleteWorkflow, + }, + { + definition: updateNodePromptTool, + handler: handleUpdateNodePrompt, + }, + { + definition: importPromptToNodeTool, + handler: handleImportPromptToNode, + }, + { + definition: addNodeTool, + handler: handleAddNode, + }, + { + definition: removeNodeTool, + handler: handleRemoveNode, + }, + { + definition: connectNodesTool, + handler: handleConnectNodes, + }, + { + definition: disconnectNodesTool, + handler: handleDisconnectNodes, + }, + ], + + // Optional: Feature can be conditionally enabled based on context + // isEnabled: async (context) => { + // // Example: Check entitlements, feature flags, etc. + // return true; + // }, +}; diff --git a/web/src/features/mcp/features/workflows/tools/_helpers.ts b/web/src/features/mcp/features/workflows/tools/_helpers.ts new file mode 100644 index 000000000000..96ef929bc665 --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/_helpers.ts @@ -0,0 +1,84 @@ +/** + * Shared helpers for workflow node-level MCP tools + * + * Provides fetch and save primitives used by all node/edge mutation tools. + */ + +import { prisma } from "@langfuse/shared/src/db"; +import { Prisma } from "@langfuse/shared"; +import { UserInputError } from "../../../core/errors"; + +export interface WorkflowNodeShape { + id: string; + type: string; + position: { x: number; y: number }; + data: Record; +} + +export interface WorkflowEdgeShape { + id: string; + source: string; + target: string; + sourceHandle?: string; + targetHandle?: string; + data?: { + edgeType?: "default" | "conditional" | "loop"; + condition?: Record; // EdgeConditionExpr + conditionLabel?: string; + maxIterations?: number; + }; +} + +export interface WorkflowDefinitionShape { + nodes: WorkflowNodeShape[]; + edges: WorkflowEdgeShape[]; +} + +/** + * Fetch the latest workflow version by name within a project. + * Throws if not found. + */ +export async function fetchLatestWorkflow( + projectId: string, + workflowName: string, +) { + const workflow = await prisma.workflow.findFirst({ + where: { projectId, name: workflowName }, + orderBy: { version: "desc" }, + }); + + if (!workflow) { + throw new UserInputError(`Workflow '${workflowName}' not found`); + } + + return workflow; +} + +/** + * Save a modified workflow definition as a new immutable version. + */ +export async function saveAsNewVersion(params: { + projectId: string; + name: string; + description: string; + tags: string[]; + definition: WorkflowDefinitionShape; + inputSchema?: unknown; + currentVersion: number; +}) { + return prisma.workflow.create({ + data: { + projectId: params.projectId, + createdBy: "API", + name: params.name, + description: params.description, + version: params.currentVersion + 1, + tags: params.tags, + definition: params.definition as unknown as Prisma.InputJsonValue, + inputSchema: + params.inputSchema !== undefined + ? (params.inputSchema as Prisma.InputJsonValue) + : Prisma.JsonNull, + }, + }); +} diff --git a/web/src/features/mcp/features/workflows/tools/addNode.ts b/web/src/features/mcp/features/workflows/tools/addNode.ts new file mode 100644 index 000000000000..32a7c70afc78 --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/addNode.ts @@ -0,0 +1,214 @@ +/** + * MCP Tool: addNode + * + * Adds a new node to a workflow definition. + * Fetches the latest version, appends the node, saves as a new version. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamWorkflowName } from "../validation"; +import { UserInputError } from "../../../core/errors"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { + fetchLatestWorkflow, + saveAsNewVersion, + type WorkflowDefinitionShape, +} from "./_helpers"; + +/** + * Base schema for addNode tool + */ +const AddNodeBaseSchema = z.object({ + workflowName: ParamWorkflowName, + nodeType: z + .enum(["agent", "input", "output", "router"]) + .describe("The type of node to add"), + nodeId: z + .string() + .min(1) + .describe("Unique ID for the new node (must be unique within workflow)"), + label: z.string().min(1).describe("Display label for the node"), + position: z + .object({ + x: z.number().describe("X coordinate on the canvas"), + y: z.number().describe("Y coordinate on the canvas"), + }) + .describe("Position of the node on the workflow canvas"), + config: z + .object({ + messages: z + .array( + z.object({ + role: z.enum(["system", "user", "assistant"]), + content: z.string(), + }), + ) + .optional() + .describe("Initial prompt messages (agent nodes only)"), + modelParams: z + .object({ + provider: z.string(), + model: z.string(), + adapter: z.string(), + temperature: z.number().optional(), + max_tokens: z.number().optional(), + top_p: z.number().optional(), + }) + .optional() + .describe("Model parameters (agent nodes only)"), + routeField: z + .string() + .optional() + .describe( + "JSON path to evaluate in upstream output (router nodes only), e.g. 'output.category'", + ), + executionMode: z + .enum(["llm", "tool", "passthrough"]) + .optional() + .describe( + "Execution mode for agent nodes: 'llm' (default), 'tool', or 'passthrough'", + ), + contextReads: z + .array(z.string()) + .optional() + .describe("Keys to read from shared workflow context"), + contextWrites: z + .array(z.string()) + .optional() + .describe("Keys to write to shared workflow context"), + }) + .optional() + .describe( + "Optional configuration for the node (agent: messages, modelParams, executionMode; router: routeField)", + ), +}); + +/** + * addNode tool definition and handler + */ +export const [addNodeTool, handleAddNode] = defineTool({ + name: "addNode", + description: [ + "Add a new node to a workflow.", + "", + "Node types:", + "- 'agent': LLM agent node - accepts messages, modelParams, executionMode, contextReads, contextWrites in config", + "- 'input': Workflow input node - receives external inputs", + "- 'output': Workflow output node - produces final results", + "- 'router': Conditional routing node - accepts routeField in config. Pure control-flow, no LLM call.", + "", + "Important:", + "- nodeId must be unique within the workflow", + "- Workflows are immutable - this creates a new version", + "- Use connectNodes to wire the new node into the graph", + "", + "Accepts: workflowName, nodeType, nodeId, label, position ({x, y}), optional config", + ].join("\n"), + baseSchema: AddNodeBaseSchema, + inputSchema: AddNodeBaseSchema, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.add_node", spanKind: SpanKind.INTERNAL }, + async (span) => { + const { workflowName, nodeType, nodeId, label, position, config } = + input; + + // Set span attributes for observability + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": workflowName, + "mcp.node_id": nodeId, + "mcp.node_type": nodeType, + }); + + // Fetch the latest workflow version + const workflow = await fetchLatestWorkflow( + context.projectId, + workflowName, + ); + + const definition = + workflow.definition as unknown as WorkflowDefinitionShape; + + // Ensure nodeId is unique + const exists = definition.nodes.some((n) => n.id === nodeId); + if (exists) { + throw new UserInputError( + `A node with id '${nodeId}' already exists in workflow '${workflowName}'`, + ); + } + + // Build new node data + const nodeData: Record = { label }; + if (nodeType === "agent" && config) { + if (config.messages) nodeData.messages = config.messages; + if (config.modelParams) nodeData.modelParams = config.modelParams; + if (config.executionMode) + nodeData.executionMode = config.executionMode; + if (config.contextReads) nodeData.contextReads = config.contextReads; + if (config.contextWrites) + nodeData.contextWrites = config.contextWrites; + } + if (nodeType === "router" && config) { + if (!config.routeField) { + throw new UserInputError( + "Router nodes require 'routeField' in config", + ); + } + nodeData.routeField = config.routeField; + } + + const newNode = { + id: nodeId, + type: nodeType, + position, + data: nodeData, + }; + + const updatedDefinition: WorkflowDefinitionShape = { + ...definition, + nodes: [...definition.nodes, newNode], + }; + + const before = workflow; + + // Save as new version + const newWorkflow = await saveAsNewVersion({ + projectId: context.projectId, + name: workflow.name, + description: workflow.description, + tags: workflow.tags, + definition: updatedDefinition, + inputSchema: workflow.inputSchema, + currentVersion: workflow.version, + }); + + span.setAttribute("mcp.new_version", newWorkflow.version); + + await auditLog({ + action: "update", + resourceType: "workflow", + resourceId: newWorkflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before, + after: newWorkflow, + }); + + return { + id: newWorkflow.id, + name: newWorkflow.name, + version: newWorkflow.version, + addedNode: newNode, + message: `Successfully added ${nodeType} node '${nodeId}' to workflow '${workflowName}' (new version: ${newWorkflow.version})`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/connectNodes.ts b/web/src/features/mcp/features/workflows/tools/connectNodes.ts new file mode 100644 index 000000000000..956d485ab2aa --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/connectNodes.ts @@ -0,0 +1,256 @@ +/** + * MCP Tool: connectNodes + * + * Adds a directed edge between two existing nodes in a workflow. + * Fetches the latest version, appends the edge, saves as a new version. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamWorkflowName } from "../validation"; +import { UserInputError } from "../../../core/errors"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { + fetchLatestWorkflow, + saveAsNewVersion, + type WorkflowDefinitionShape, + type WorkflowEdgeShape, +} from "./_helpers"; + +/** + * Base schema for connectNodes tool + */ +const EdgeConditionSchema = z.object({ + field: z + .string() + .describe("JSON path in upstream output, e.g. 'output.category'"), + operator: z.enum([ + "equals", + "not_equals", + "contains", + "not_contains", + "regex_match", + "greater_than", + "less_than", + "is_empty", + "is_not_empty", + ]), + value: z.string().optional().describe("Comparison value"), +}); + +const EdgeConditionGroupSchema: z.ZodType<{ + logic: "and" | "or"; + conditions: unknown[]; +}> = z.object({ + logic: z.enum(["and", "or"]), + conditions: z.lazy(() => + z.array(z.union([EdgeConditionSchema, EdgeConditionGroupSchema])), + ), +}); + +const EdgeConditionExprSchema = z.union([ + EdgeConditionSchema, + EdgeConditionGroupSchema, +]); + +const ConnectNodesBaseSchema = z.object({ + workflowName: ParamWorkflowName, + sourceNodeId: z + .string() + .min(1) + .describe("The ID of the source node (edge originates here)"), + targetNodeId: z + .string() + .min(1) + .describe("The ID of the target node (edge terminates here)"), + sourceHandle: z + .string() + .optional() + .describe("Optional handle ID on the source node"), + targetHandle: z + .string() + .optional() + .describe("Optional handle ID on the target node"), + edgeType: z + .enum(["default", "conditional", "loop"]) + .optional() + .default("default") + .describe( + "Edge type: 'default' (unconditional), 'conditional' (router branch), 'loop' (back-edge with iteration limit)", + ), + condition: EdgeConditionExprSchema.optional().describe( + "Condition for conditional edges. Single: {field, operator, value}. Compound: {logic: 'and'|'or', conditions: [...]}", + ), + conditionLabel: z + .string() + .optional() + .describe( + "Human-readable label for the condition, e.g. 'category = sports'", + ), + maxIterations: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Max iterations for loop edges (default 10, max 100)"), +}); + +/** + * connectNodes tool definition and handler + */ +export const [connectNodesTool, handleConnectNodes] = defineTool({ + name: "connectNodes", + description: [ + "Add a directed edge between two existing nodes in a workflow.", + "", + "Edge types:", + "- 'default': Unconditional edge (standard data flow)", + "- 'conditional': Conditional edge from a router node. Requires 'condition' parameter.", + "- 'loop': Back-edge for creating loops. Source must be a router node. Optional 'maxIterations' (default 10).", + "", + "Important:", + "- Both source and target nodes must already exist in the workflow", + "- Workflows are immutable - this creates a new version", + "- Edge ID is auto-generated", + "- Use sourceHandle/targetHandle if the nodes expose multiple connection points", + "- Conditional edges require a condition (single or compound AND/OR)", + "- Loop edges source must be a router node", + "", + "Accepts: workflowName, sourceNodeId, targetNodeId, edgeType, condition, conditionLabel, maxIterations", + ].join("\n"), + baseSchema: ConnectNodesBaseSchema, + inputSchema: ConnectNodesBaseSchema, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.connect_nodes", spanKind: SpanKind.INTERNAL }, + async (span) => { + const { + workflowName, + sourceNodeId, + targetNodeId, + sourceHandle, + targetHandle, + edgeType, + condition, + conditionLabel, + maxIterations, + } = input; + + // Set span attributes for observability + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": workflowName, + "mcp.source_node_id": sourceNodeId, + "mcp.target_node_id": targetNodeId, + }); + + // Fetch the latest workflow version + const workflow = await fetchLatestWorkflow( + context.projectId, + workflowName, + ); + + const definition = + workflow.definition as unknown as WorkflowDefinitionShape; + + // Validate that both nodes exist + const sourceExists = definition.nodes.some( + (n) => n.id === sourceNodeId, + ); + if (!sourceExists) { + throw new UserInputError( + `Source node '${sourceNodeId}' not found in workflow '${workflowName}'`, + ); + } + + const targetExists = definition.nodes.some( + (n) => n.id === targetNodeId, + ); + if (!targetExists) { + throw new UserInputError( + `Target node '${targetNodeId}' not found in workflow '${workflowName}'`, + ); + } + + // Validate edge type constraints + if (edgeType === "conditional" && !condition) { + throw new UserInputError( + "Conditional edges require a 'condition' parameter", + ); + } + const sourceNode = definition.nodes.find((n) => n.id === sourceNodeId); + // Validate no adjacent routers + const targetNode = definition.nodes.find((n) => n.id === targetNodeId); + if ( + sourceNode?.type === "router" && + targetNode?.type === "router" && + edgeType !== "loop" + ) { + throw new UserInputError( + "Direct router-to-router connections are not allowed. Place an agent node in between.", + ); + } + + // Build new edge with auto-generated ID + const edgeId = `edge-${sourceNodeId}-${targetNodeId}-${Date.now()}`; + const edgeData: Record = { edgeType }; + if (condition) edgeData.condition = condition; + if (conditionLabel) edgeData.conditionLabel = conditionLabel; + if (maxIterations !== undefined) edgeData.maxIterations = maxIterations; + + const newEdge: WorkflowEdgeShape = { + id: edgeId, + source: sourceNodeId, + target: targetNodeId, + ...(sourceHandle ? { sourceHandle } : {}), + ...(targetHandle ? { targetHandle } : {}), + ...(edgeType !== "default" ? { data: edgeData } : {}), + }; + + const updatedDefinition: WorkflowDefinitionShape = { + ...definition, + edges: [...definition.edges, newEdge], + }; + + const before = workflow; + + // Save as new version + const newWorkflow = await saveAsNewVersion({ + projectId: context.projectId, + name: workflow.name, + description: workflow.description, + tags: workflow.tags, + definition: updatedDefinition, + inputSchema: workflow.inputSchema, + currentVersion: workflow.version, + }); + + span.setAttribute("mcp.new_version", newWorkflow.version); + + await auditLog({ + action: "update", + resourceType: "workflow", + resourceId: newWorkflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before, + after: newWorkflow, + }); + + return { + id: newWorkflow.id, + name: newWorkflow.name, + version: newWorkflow.version, + addedEdge: newEdge, + message: `Successfully connected '${sourceNodeId}' → '${targetNodeId}' in workflow '${workflowName}' (new version: ${newWorkflow.version})`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/createWorkflow.ts b/web/src/features/mcp/features/workflows/tools/createWorkflow.ts new file mode 100644 index 000000000000..eaff18e56c95 --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/createWorkflow.ts @@ -0,0 +1,148 @@ +/** + * MCP Tool: createWorkflow + * + * Creates a new workflow version in Langfuse. + * Write operation. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { Prisma } from "@langfuse/shared"; +import { prisma } from "@langfuse/shared/src/db"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { + WorkflowDefinitionSchema, + WorkflowNameSchema, +} from "@/src/features/workflow-editor/server/validation"; + +/** + * Base schema for JSON Schema generation (MCP client display) + * Uses simple types that serialize well to JSON Schema + */ +const CreateWorkflowBaseSchema = z.object({ + name: z + .string() + .min(1) + .max(100) + .describe( + "The name of the workflow (alphanumeric, spaces, hyphens, periods, underscores)", + ), + description: z + .string() + .max(500) + .optional() + .describe("Optional description of the workflow"), + tags: z + .array(z.string()) + .optional() + .describe( + "Optional tags for organization (e.g., ['production', 'experimental'])", + ), + definition: z + .object({ + nodes: z.array(z.any()), + edges: z.array(z.any()), + }) + .describe("Workflow graph definition with nodes and edges"), + inputSchema: z + .any() + .optional() + .describe("Optional JSON Schema for workflow inputs"), +}); + +/** + * Input schema for runtime validation + * Uses full validation schemas from workflow-editor + */ +const CreateWorkflowInputSchema = z.object({ + name: WorkflowNameSchema, + description: z.string().max(500).optional(), + tags: z.array(z.string()).optional(), + definition: WorkflowDefinitionSchema, + inputSchema: z.any().optional(), +}); + +/** + * createWorkflow tool definition and handler + */ +export const [createWorkflowTool, handleCreateWorkflow] = defineTool({ + name: "createWorkflow", + description: [ + "Create a new workflow in Langfuse.", + "", + "Important:", + "- Workflows use immutable versioning - each create/update creates a new version", + "- If a workflow with this name already exists, a new version is created", + "- The definition must include nodes and edges arrays", + "- Node types: 'agent', 'input', 'output'", + "", + "Accepts: name, definition (nodes + edges), optional description, tags, inputSchema", + ].join("\n"), + baseSchema: CreateWorkflowBaseSchema, + inputSchema: CreateWorkflowInputSchema, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.create", spanKind: SpanKind.INTERNAL }, + async (span) => { + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": input.name, + }); + + // Determine version number (find highest version for this name + 1) + const existingWorkflows = await prisma.workflow.findMany({ + where: { + projectId: context.projectId, + name: input.name, + }, + select: { version: true }, + orderBy: { version: "desc" }, + take: 1, + }); + + const nextVersion = + existingWorkflows.length > 0 ? existingWorkflows[0]!.version + 1 : 1; + + const workflow = await prisma.workflow.create({ + data: { + projectId: context.projectId, + createdBy: "API", + name: input.name, + description: input.description ?? "", + version: nextVersion, + tags: input.tags ?? [], + definition: input.definition as Prisma.InputJsonValue, + inputSchema: input.inputSchema as Prisma.InputJsonValue | undefined, + }, + }); + + span.setAttribute("mcp.created_version", workflow.version); + + await auditLog({ + action: "create", + resourceType: "workflow", + resourceId: workflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + after: workflow, + }); + + return { + id: workflow.id, + name: workflow.name, + version: workflow.version, + description: workflow.description, + tags: workflow.tags, + createdAt: workflow.createdAt, + createdBy: workflow.createdBy, + message: `Successfully created workflow '${workflow.name}' version ${workflow.version}`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/deleteWorkflow.ts b/web/src/features/mcp/features/workflows/tools/deleteWorkflow.ts new file mode 100644 index 000000000000..46ded2be55cb --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/deleteWorkflow.ts @@ -0,0 +1,126 @@ +/** + * MCP Tool: deleteWorkflow + * + * Deletes a workflow (or specific version) from Langfuse. + * Destructive operation. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { prisma } from "@langfuse/shared/src/db"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { WorkflowNameSchema } from "@/src/features/workflow-editor/server/validation"; +import { UserInputError } from "../../../core/errors"; + +/** + * Base schema for JSON Schema generation (MCP client display) + */ +const DeleteWorkflowBaseSchema = z.object({ + name: z + .string() + .min(1) + .max(100) + .describe("The name of the workflow to delete"), + version: z + .number() + .int() + .positive() + .optional() + .describe( + "Specific version to delete. If omitted, ALL versions of this workflow are deleted.", + ), +}); + +/** + * Input schema for runtime validation + */ +const DeleteWorkflowInputSchema = z.object({ + name: WorkflowNameSchema, + version: z.number().int().positive().optional(), +}); + +/** + * deleteWorkflow tool definition and handler + */ +export const [deleteWorkflowTool, handleDeleteWorkflow] = defineTool({ + name: "deleteWorkflow", + description: [ + "Delete a workflow from Langfuse.", + "", + "Important:", + "- If version is provided, only that specific version is deleted", + "- If version is omitted, ALL versions of the workflow are deleted", + "- This operation is irreversible", + "", + "Accepts: name (required), version (optional)", + ].join("\n"), + baseSchema: DeleteWorkflowBaseSchema, + inputSchema: DeleteWorkflowInputSchema, + destructiveHint: true, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.delete", spanKind: SpanKind.INTERNAL }, + async (span) => { + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": input.name, + }); + + // Find workflows to delete + const workflowsToDelete = await prisma.workflow.findMany({ + where: { + projectId: context.projectId, + name: input.name, + ...(input.version !== undefined ? { version: input.version } : {}), + }, + }); + + if (workflowsToDelete.length === 0) { + const versionSuffix = + input.version !== undefined ? ` version ${input.version}` : ""; + throw new UserInputError( + `Workflow '${input.name}'${versionSuffix} not found in this project`, + ); + } + + // Delete all matched workflow records + const { count } = await prisma.workflow.deleteMany({ + where: { + projectId: context.projectId, + name: input.name, + ...(input.version !== undefined ? { version: input.version } : {}), + }, + }); + + span.setAttribute("mcp.deleted_count", count); + + // Audit log each deleted workflow + for (const workflow of workflowsToDelete) { + await auditLog({ + action: "delete", + resourceType: "workflow", + resourceId: workflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before: workflow, + }); + } + + const versionSuffix = + input.version !== undefined + ? ` version ${input.version}` + : ` (${count} version${count !== 1 ? "s" : ""})`; + + return { + message: `Successfully deleted workflow '${input.name}'${versionSuffix}`, + deletedCount: count, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/disconnectNodes.ts b/web/src/features/mcp/features/workflows/tools/disconnectNodes.ts new file mode 100644 index 000000000000..99fca10d4dd1 --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/disconnectNodes.ts @@ -0,0 +1,136 @@ +/** + * MCP Tool: disconnectNodes + * + * Removes the edge(s) between two nodes in a workflow. + * Fetches the latest version, filters out matching edges, saves as a new version. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamWorkflowName } from "../validation"; +import { UserInputError } from "../../../core/errors"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { + fetchLatestWorkflow, + saveAsNewVersion, + type WorkflowDefinitionShape, + type WorkflowEdgeShape, +} from "./_helpers"; + +/** + * Base schema for disconnectNodes tool + */ +const DisconnectNodesBaseSchema = z.object({ + workflowName: ParamWorkflowName, + sourceNodeId: z + .string() + .min(1) + .describe("The ID of the source node of the edge to remove"), + targetNodeId: z + .string() + .min(1) + .describe("The ID of the target node of the edge to remove"), +}); + +/** + * disconnectNodes tool definition and handler + */ +export const [disconnectNodesTool, handleDisconnectNodes] = defineTool({ + name: "disconnectNodes", + description: [ + "Remove the edge(s) between two nodes in a workflow.", + "", + "Important:", + "- Removes all edges where source matches sourceNodeId AND target matches targetNodeId", + "- Workflows are immutable - this creates a new version", + "- This operation cannot be undone (previous version still accessible by version number)", + "", + "Accepts: workflowName, sourceNodeId, targetNodeId", + ].join("\n"), + baseSchema: DisconnectNodesBaseSchema, + inputSchema: DisconnectNodesBaseSchema, + destructiveHint: true, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.disconnect_nodes", spanKind: SpanKind.INTERNAL }, + async (span) => { + const { workflowName, sourceNodeId, targetNodeId } = input; + + // Set span attributes for observability + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": workflowName, + "mcp.source_node_id": sourceNodeId, + "mcp.target_node_id": targetNodeId, + }); + + // Fetch the latest workflow version + const workflow = await fetchLatestWorkflow( + context.projectId, + workflowName, + ); + + const definition = + workflow.definition as unknown as WorkflowDefinitionShape; + + // Find matching edges to remove + const removedEdgeIds: string[] = []; + const updatedEdges = definition.edges.filter((e: WorkflowEdgeShape) => { + const matches = + e.source === sourceNodeId && e.target === targetNodeId; + if (matches) removedEdgeIds.push(e.id); + return !matches; + }); + + if (removedEdgeIds.length === 0) { + throw new UserInputError( + `No edge found from '${sourceNodeId}' to '${targetNodeId}' in workflow '${workflowName}'`, + ); + } + + const updatedDefinition: WorkflowDefinitionShape = { + ...definition, + edges: updatedEdges, + }; + + const before = workflow; + + // Save as new version + const newWorkflow = await saveAsNewVersion({ + projectId: context.projectId, + name: workflow.name, + description: workflow.description, + tags: workflow.tags, + definition: updatedDefinition, + inputSchema: workflow.inputSchema, + currentVersion: workflow.version, + }); + + span.setAttribute("mcp.new_version", newWorkflow.version); + + await auditLog({ + action: "update", + resourceType: "workflow", + resourceId: newWorkflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before, + after: newWorkflow, + }); + + return { + id: newWorkflow.id, + name: newWorkflow.name, + version: newWorkflow.version, + removedEdgeIds, + message: `Successfully removed ${removedEdgeIds.length} edge(s) from '${sourceNodeId}' → '${targetNodeId}' in workflow '${workflowName}' (new version: ${newWorkflow.version})`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/getWorkflow.ts b/web/src/features/mcp/features/workflows/tools/getWorkflow.ts new file mode 100644 index 000000000000..a47ad2d11327 --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/getWorkflow.ts @@ -0,0 +1,92 @@ +/** + * MCP Tool: getWorkflow + * + * Fetches a specific workflow by name with optional version. + * Read-only operation. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamWorkflowName, ParamWorkflowVersion } from "../validation"; +import { UserInputError } from "../../../core/errors"; +import { prisma } from "@langfuse/shared/src/db"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; + +/** + * Base schema for getWorkflow tool + */ +const GetWorkflowBaseSchema = z.object({ + name: ParamWorkflowName, + version: ParamWorkflowVersion, +}); + +/** + * getWorkflow tool definition and handler + */ +export const [getWorkflowTool, handleGetWorkflow] = defineTool({ + name: "getWorkflow", + description: [ + "Fetch a specific workflow by name with optional version parameter.", + "", + "Retrieval options:", + "- version: Get specific version number (e.g., 1, 2, 3)", + "- neither: Returns the latest version by default", + "", + "Returns full workflow including definition (nodes and edges) and metadata.", + ].join("\n"), + baseSchema: GetWorkflowBaseSchema, + inputSchema: GetWorkflowBaseSchema, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.get", spanKind: SpanKind.INTERNAL }, + async (span) => { + const { name, version } = input; + + // Set span attributes for observability + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": name, + }); + + if (version) { + span.setAttribute("mcp.workflow_version", version); + } + + // If version is provided, get that specific version; otherwise get latest + const workflow = await prisma.workflow.findFirst({ + where: { + projectId: context.projectId, // Auto-injected from authenticated API key + name, + ...(version ? { version } : {}), + }, + orderBy: version ? undefined : { version: "desc" }, + }); + + if (!workflow) { + throw new UserInputError( + `Workflow '${name}' not found${version ? ` with version ${version}` : ""}`, + ); + } + + // Return formatted response with full definition + return { + id: workflow.id, + name: workflow.name, + description: workflow.description, + version: workflow.version, + tags: workflow.tags, + definition: workflow.definition, + inputSchema: workflow.inputSchema, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + createdBy: workflow.createdBy, + projectId: workflow.projectId, + }; + }, + ); + }, + readOnlyHint: true, +}); diff --git a/web/src/features/mcp/features/workflows/tools/importPromptToNode.ts b/web/src/features/mcp/features/workflows/tools/importPromptToNode.ts new file mode 100644 index 000000000000..6db6f08ec9d5 --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/importPromptToNode.ts @@ -0,0 +1,249 @@ +/** + * MCP Tool: importPromptToNode + * + * Imports a registered Langfuse prompt (by name) into a workflow agent node. + * Fetches the prompt, extracts messages and config, and applies them to the node. + * Creates a new workflow version. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamWorkflowName } from "../validation"; +import { + ParamPromptName, + ParamPromptLabel, + ParamPromptVersion, +} from "../../prompts/validation"; +import { UserInputError } from "../../../core/errors"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { getPromptByName } from "@/src/features/prompts/server/actions/getPromptByName"; +import { + fetchLatestWorkflow, + saveAsNewVersion, + type WorkflowDefinitionShape, + type WorkflowNodeShape, +} from "./_helpers"; + +/** + * Base schema for importPromptToNode tool + */ +const ImportPromptToNodeBaseSchema = z.object({ + workflowName: ParamWorkflowName, + nodeId: z + .string() + .min(1) + .describe("The ID of the agent node to import the prompt into"), + promptName: ParamPromptName, + promptLabel: ParamPromptLabel, + promptVersion: ParamPromptVersion, + applyConfig: z + .boolean() + .optional() + .default(true) + .describe( + "Whether to apply the prompt's config (model parameters) to the node. Default: true", + ), +}); + +/** + * Full input schema with runtime validations + */ +const ImportPromptToNodeInputSchema = ImportPromptToNodeBaseSchema.refine( + (data) => !(data.promptLabel && data.promptVersion), + { + message: + "Cannot specify both promptLabel and promptVersion - they are mutually exclusive", + }, +); + +/** + * importPromptToNode tool definition and handler + */ +export const [importPromptToNodeTool, handleImportPromptToNode] = defineTool({ + name: "importPromptToNode", + description: [ + "Import a registered Langfuse prompt into a workflow agent node.", + "", + "This fetches a prompt from Langfuse's Prompt Management by name and applies", + "its messages and optionally its config (model parameters) to the specified agent node.", + "", + "Prompt resolution:", + "- promptLabel: Get prompt with specific label (e.g., 'production', 'staging')", + "- promptVersion: Get specific version number (e.g., 1, 2, 3)", + "- neither: Returns 'production' version by default", + "", + "What gets applied:", + "- messages: The prompt's chat messages (system, user, assistant) replace existing messages", + "- config: If applyConfig=true, the prompt's config (provider, model, temperature, etc.) is applied as modelParams", + "- promptId and promptVersion: Stored on the node as a reference back to the source prompt", + "", + "Important:", + "- Only works on 'agent' nodes (not input, output, or router)", + "- Only 'chat' type prompts can be imported (not 'text')", + "- Workflows are immutable - this creates a new version", + "- promptLabel and promptVersion are mutually exclusive", + "", + "Accepts: workflowName, nodeId, promptName, promptLabel?, promptVersion?, applyConfig?", + ].join("\n"), + baseSchema: ImportPromptToNodeBaseSchema, + inputSchema: ImportPromptToNodeInputSchema, + handler: async (input, context) => { + return await instrumentAsync( + { + name: "mcp.workflows.import_prompt_to_node", + spanKind: SpanKind.INTERNAL, + }, + async (span) => { + const { + workflowName, + nodeId, + promptName, + promptLabel, + promptVersion, + applyConfig, + } = input; + + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": workflowName, + "mcp.node_id": nodeId, + "mcp.prompt_name": promptName, + }); + + // 1. Fetch the prompt from Langfuse + const prompt = await getPromptByName({ + promptName, + projectId: context.projectId, + label: promptLabel, + version: promptVersion, + }); + + if (!prompt) { + throw new UserInputError( + `Prompt '${promptName}' not found${promptLabel ? ` with label '${promptLabel}'` : ""}${promptVersion ? ` with version ${promptVersion}` : ""}`, + ); + } + + if (prompt.type !== "chat") { + throw new UserInputError( + `Prompt '${promptName}' is of type '${prompt.type}'. Only 'chat' type prompts can be imported into workflow nodes.`, + ); + } + + // Extract messages from prompt + const messages = Array.isArray(prompt.prompt) + ? (prompt.prompt as Array<{ role: string; content: string }>) + : []; + + if (messages.length === 0) { + throw new UserInputError( + `Prompt '${promptName}' has no messages to import.`, + ); + } + + // 2. Fetch the latest workflow version + const workflow = await fetchLatestWorkflow( + context.projectId, + workflowName, + ); + + const definition = + workflow.definition as unknown as WorkflowDefinitionShape; + + // 3. Find and validate the target node + const nodeIndex = definition.nodes.findIndex( + (n: WorkflowNodeShape) => n.id === nodeId, + ); + if (nodeIndex === -1) { + throw new UserInputError( + `Node '${nodeId}' not found in workflow '${workflowName}'`, + ); + } + + const node = definition.nodes[nodeIndex]; + if (node.type !== "agent") { + throw new UserInputError( + `Node '${nodeId}' is of type '${node.type}'. importPromptToNode only works on 'agent' nodes.`, + ); + } + + // 4. Build updated node data + const updatedData: Record = { + ...node.data, + messages, + promptId: prompt.id, + promptVersion: prompt.version, + }; + + // Apply config as modelParams if requested + if (applyConfig && prompt.config) { + const config = prompt.config as Record; + // Map prompt config to modelParams structure + if (Object.keys(config).length > 0) { + updatedData.modelParams = { + ...(node.data.modelParams as Record | undefined), + ...config, + }; + } + } + + const updatedNode: WorkflowNodeShape = { + ...node, + data: updatedData, + }; + + const updatedDefinition: WorkflowDefinitionShape = { + ...definition, + nodes: definition.nodes.map((n: WorkflowNodeShape, i: number) => + i === nodeIndex ? updatedNode : n, + ), + }; + + const before = workflow; + + // 5. Save as new version + const newWorkflow = await saveAsNewVersion({ + projectId: context.projectId, + name: workflow.name, + description: workflow.description, + tags: workflow.tags, + definition: updatedDefinition, + inputSchema: workflow.inputSchema, + currentVersion: workflow.version, + }); + + span.setAttribute("mcp.new_version", newWorkflow.version); + + await auditLog({ + action: "update", + resourceType: "workflow", + resourceId: newWorkflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before, + after: newWorkflow, + }); + + return { + id: newWorkflow.id, + name: newWorkflow.name, + version: newWorkflow.version, + nodeId, + promptName: prompt.name, + promptVersion: prompt.version, + promptId: prompt.id, + messagesCount: messages.length, + configApplied: + applyConfig && + Object.keys((prompt.config as object) ?? {}).length > 0, + message: `Successfully imported prompt '${promptName}' (v${prompt.version}) into node '${nodeId}' in workflow '${workflowName}' (new version: ${newWorkflow.version})`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/listWorkflows.ts b/web/src/features/mcp/features/workflows/tools/listWorkflows.ts new file mode 100644 index 000000000000..f7cfcc44c9fb --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/listWorkflows.ts @@ -0,0 +1,113 @@ +/** + * MCP Tool: listWorkflows + * + * Lists workflows in the project with pagination. + * Read-only operation. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamLimit } from "../../../core/validation"; +import { prisma } from "@langfuse/shared/src/db"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; + +const ParamOffset = z.coerce + .number() + .int() + .min(0) + .default(0) + .describe("Number of items to skip for pagination (default: 0)"); + +/** + * Base schema for listWorkflows tool + */ +const ListWorkflowsBaseSchema = z.object({ + limit: ParamLimit, + offset: ParamOffset, +}); + +/** + * listWorkflows tool definition and handler + */ +export const [listWorkflowsTool, handleListWorkflows] = defineTool({ + name: "listWorkflows", + description: [ + "List workflows in the project. Returns the latest version of each workflow with metadata.", + "", + "Pagination: limit (default: 50, max: 100), offset (default: 0)", + ].join("\n"), + baseSchema: ListWorkflowsBaseSchema, + inputSchema: ListWorkflowsBaseSchema, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.list", spanKind: SpanKind.INTERNAL }, + async (span) => { + const { limit, offset } = input; + + // Set span attributes for observability + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.pagination_limit": limit, + "mcp.pagination_offset": offset, + }); + + // Get all workflows for the project, ordered to allow deduplication by name + const allWorkflows = await prisma.workflow.findMany({ + where: { projectId: context.projectId }, + orderBy: [ + { name: "asc" }, + { version: "desc" }, + { updatedAt: "desc" }, + ], + select: { + id: true, + name: true, + description: true, + version: true, + tags: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Get latest version of each workflow (DISTINCT ON name) + const workflowsByName = new Map(); + for (const workflow of allWorkflows) { + if (!workflowsByName.has(workflow.name)) { + workflowsByName.set(workflow.name, workflow); + } + } + + const workflows = Array.from(workflowsByName.values()).slice( + offset, + offset + limit, + ); + + // Set result count for observability + span.setAttribute("mcp.result_count", workflows.length); + + // Return formatted response + return { + data: workflows.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + version: w.version, + tags: w.tags, + createdAt: w.createdAt, + updatedAt: w.updatedAt, + })), + pagination: { + limit, + offset, + totalItems: workflowsByName.size, + }, + }; + }, + ); + }, + readOnlyHint: true, +}); diff --git a/web/src/features/mcp/features/workflows/tools/removeNode.ts b/web/src/features/mcp/features/workflows/tools/removeNode.ts new file mode 100644 index 000000000000..935a9ffb0158 --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/removeNode.ts @@ -0,0 +1,128 @@ +/** + * MCP Tool: removeNode + * + * Removes a node and all its connected edges from a workflow. + * Fetches the latest version, filters out the node and edges, saves as new version. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamWorkflowName } from "../validation"; +import { UserInputError } from "../../../core/errors"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { + fetchLatestWorkflow, + saveAsNewVersion, + type WorkflowDefinitionShape, + type WorkflowEdgeShape, +} from "./_helpers"; + +/** + * Base schema for removeNode tool + */ +const RemoveNodeBaseSchema = z.object({ + workflowName: ParamWorkflowName, + nodeId: z.string().min(1).describe("The ID of the node to remove"), +}); + +/** + * removeNode tool definition and handler + */ +export const [removeNodeTool, handleRemoveNode] = defineTool({ + name: "removeNode", + description: [ + "Remove a node and all its connected edges from a workflow.", + "", + "Important:", + "- This also removes all edges where the node is source or target", + "- Workflows are immutable - this creates a new version", + "- This operation cannot be undone (previous version still accessible by version number)", + ].join("\n"), + baseSchema: RemoveNodeBaseSchema, + inputSchema: RemoveNodeBaseSchema, + destructiveHint: true, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.remove_node", spanKind: SpanKind.INTERNAL }, + async (span) => { + const { workflowName, nodeId } = input; + + // Set span attributes for observability + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": workflowName, + "mcp.node_id": nodeId, + }); + + // Fetch the latest workflow version + const workflow = await fetchLatestWorkflow( + context.projectId, + workflowName, + ); + + const definition = + workflow.definition as unknown as WorkflowDefinitionShape; + + // Verify the node exists + const nodeExists = definition.nodes.some((n) => n.id === nodeId); + if (!nodeExists) { + throw new UserInputError( + `Node '${nodeId}' not found in workflow '${workflowName}'`, + ); + } + + // Remove the node and all connected edges + const removedEdgeIds: string[] = []; + const updatedEdges = definition.edges.filter((e: WorkflowEdgeShape) => { + const connected = e.source === nodeId || e.target === nodeId; + if (connected) removedEdgeIds.push(e.id); + return !connected; + }); + + const updatedDefinition: WorkflowDefinitionShape = { + nodes: definition.nodes.filter((n) => n.id !== nodeId), + edges: updatedEdges, + }; + + const before = workflow; + + // Save as new version + const newWorkflow = await saveAsNewVersion({ + projectId: context.projectId, + name: workflow.name, + description: workflow.description, + tags: workflow.tags, + definition: updatedDefinition, + inputSchema: workflow.inputSchema, + currentVersion: workflow.version, + }); + + span.setAttribute("mcp.new_version", newWorkflow.version); + + await auditLog({ + action: "update", + resourceType: "workflow", + resourceId: newWorkflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before, + after: newWorkflow, + }); + + return { + id: newWorkflow.id, + name: newWorkflow.name, + version: newWorkflow.version, + removedNodeId: nodeId, + removedEdgeIds, + message: `Successfully removed node '${nodeId}' and ${removedEdgeIds.length} connected edge(s) from workflow '${workflowName}' (new version: ${newWorkflow.version})`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/updateNodePrompt.ts b/web/src/features/mcp/features/workflows/tools/updateNodePrompt.ts new file mode 100644 index 000000000000..5d1e8debefbb --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/updateNodePrompt.ts @@ -0,0 +1,149 @@ +/** + * MCP Tool: updateNodePrompt + * + * Updates the prompt messages of a specific agent node in a workflow. + * Fetches the latest version, modifies in memory, saves as a new version. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { ParamWorkflowName } from "../validation"; +import { UserInputError } from "../../../core/errors"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { + fetchLatestWorkflow, + saveAsNewVersion, + type WorkflowDefinitionShape, + type WorkflowNodeShape, +} from "./_helpers"; + +/** + * Base schema for updateNodePrompt tool + */ +const UpdateNodePromptBaseSchema = z.object({ + workflowName: ParamWorkflowName, + nodeId: z.string().min(1).describe("The ID of the agent node to update"), + messages: z + .array( + z.object({ + role: z + .enum(["system", "user", "assistant"]) + .describe("The role of the message sender"), + content: z.string().describe("The message content"), + }), + ) + .min(1) + .describe("The new prompt messages to set on the node"), +}); + +/** + * updateNodePrompt tool definition and handler + */ +export const [updateNodePromptTool, handleUpdateNodePrompt] = defineTool({ + name: "updateNodePrompt", + description: [ + "Update the prompt messages of a specific agent node in a workflow.", + "", + "Important:", + "- Only works on nodes with type 'agent'", + "- Workflows are immutable - this creates a new version", + "- Provide the full messages array (replaces existing messages)", + "", + "Accepts: workflowName, nodeId, messages (array of {role, content} objects)", + ].join("\n"), + baseSchema: UpdateNodePromptBaseSchema, + inputSchema: UpdateNodePromptBaseSchema, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.update_node_prompt", spanKind: SpanKind.INTERNAL }, + async (span) => { + const { workflowName, nodeId, messages } = input; + + // Set span attributes for observability + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": workflowName, + "mcp.node_id": nodeId, + }); + + // Fetch the latest workflow version + const workflow = await fetchLatestWorkflow( + context.projectId, + workflowName, + ); + + const definition = + workflow.definition as unknown as WorkflowDefinitionShape; + + // Find the target node + const nodeIndex = definition.nodes.findIndex( + (n: WorkflowNodeShape) => n.id === nodeId, + ); + if (nodeIndex === -1) { + throw new UserInputError( + `Node '${nodeId}' not found in workflow '${workflowName}'`, + ); + } + + const node = definition.nodes[nodeIndex]; + if (node.type !== "agent") { + throw new UserInputError( + `Node '${nodeId}' is of type '${node.type}', but updateNodePrompt only works on 'agent' nodes`, + ); + } + + // Update messages in memory + const updatedNode: WorkflowNodeShape = { + ...node, + data: { ...node.data, messages }, + }; + + const updatedDefinition: WorkflowDefinitionShape = { + ...definition, + nodes: definition.nodes.map((n: WorkflowNodeShape, i: number) => + i === nodeIndex ? updatedNode : n, + ), + }; + + const before = workflow; + + // Save as new version + const newWorkflow = await saveAsNewVersion({ + projectId: context.projectId, + name: workflow.name, + description: workflow.description, + tags: workflow.tags, + definition: updatedDefinition, + inputSchema: workflow.inputSchema, + currentVersion: workflow.version, + }); + + span.setAttribute("mcp.new_version", newWorkflow.version); + + await auditLog({ + action: "update", + resourceType: "workflow", + resourceId: newWorkflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before, + after: newWorkflow, + }); + + return { + id: newWorkflow.id, + name: newWorkflow.name, + version: newWorkflow.version, + nodeId, + messagesCount: messages.length, + message: `Successfully updated prompt messages on node '${nodeId}' in workflow '${workflowName}' (new version: ${newWorkflow.version})`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/tools/updateWorkflow.ts b/web/src/features/mcp/features/workflows/tools/updateWorkflow.ts new file mode 100644 index 000000000000..16d4af243e4d --- /dev/null +++ b/web/src/features/mcp/features/workflows/tools/updateWorkflow.ts @@ -0,0 +1,145 @@ +/** + * MCP Tool: updateWorkflow + * + * Creates a new version of an existing workflow in Langfuse. + * Workflows use immutable versioning - updating creates a new version. + */ + +import { z } from "zod/v4"; +import { defineTool } from "../../../core/define-tool"; +import { Prisma } from "@langfuse/shared"; +import { prisma } from "@langfuse/shared/src/db"; +import { auditLog } from "@/src/features/audit-logs/auditLog"; +import { instrumentAsync } from "@langfuse/shared/src/server"; +import { SpanKind } from "@opentelemetry/api"; +import { + WorkflowDefinitionSchema, + WorkflowNameSchema, +} from "@/src/features/workflow-editor/server/validation"; + +/** + * Base schema for JSON Schema generation (MCP client display) + */ +const UpdateWorkflowBaseSchema = z.object({ + name: z + .string() + .min(1) + .max(100) + .describe("The name of the workflow to update"), + description: z + .string() + .max(500) + .optional() + .describe("Optional description of the workflow"), + tags: z + .array(z.string()) + .optional() + .describe( + "Optional tags for organization (e.g., ['production', 'experimental'])", + ), + definition: z + .object({ + nodes: z.array(z.any()), + edges: z.array(z.any()), + }) + .describe("Updated workflow graph definition with nodes and edges"), + inputSchema: z + .any() + .optional() + .describe("Optional JSON Schema for workflow inputs"), +}); + +/** + * Input schema for runtime validation + */ +const UpdateWorkflowInputSchema = z.object({ + name: WorkflowNameSchema, + description: z.string().max(500).optional(), + tags: z.array(z.string()).optional(), + definition: WorkflowDefinitionSchema, + inputSchema: z.any().optional(), +}); + +/** + * updateWorkflow tool definition and handler + */ +export const [updateWorkflowTool, handleUpdateWorkflow] = defineTool({ + name: "updateWorkflow", + description: [ + "Update a workflow in Langfuse by creating a new version.", + "", + "Important:", + "- Workflows are immutable - updating creates a new version (version + 1)", + "- The workflow is identified by name; the latest version becomes the basis", + "- All fields in the new version must be provided (not a partial update)", + "- Node types: 'agent', 'input', 'output'", + "", + "Accepts: name, definition (nodes + edges), optional description, tags, inputSchema", + ].join("\n"), + baseSchema: UpdateWorkflowBaseSchema, + inputSchema: UpdateWorkflowInputSchema, + handler: async (input, context) => { + return await instrumentAsync( + { name: "mcp.workflows.update", spanKind: SpanKind.INTERNAL }, + async (span) => { + span.setAttributes({ + "langfuse.project.id": context.projectId, + "langfuse.org.id": context.orgId, + "mcp.api_key_id": context.apiKeyId, + "mcp.workflow_name": input.name, + }); + + // Get the latest existing version for audit log (before state) + const existingWorkflow = await prisma.workflow.findFirst({ + where: { + projectId: context.projectId, + name: input.name, + }, + orderBy: { version: "desc" }, + }); + + // Determine next version number + const nextVersion = + existingWorkflow !== null ? existingWorkflow.version + 1 : 1; + + // Create a new version (immutable versioning) + const newWorkflow = await prisma.workflow.create({ + data: { + projectId: context.projectId, + createdBy: "API", + name: input.name, + description: input.description ?? "", + version: nextVersion, + tags: input.tags ?? [], + definition: input.definition as Prisma.InputJsonValue, + inputSchema: input.inputSchema as Prisma.InputJsonValue | undefined, + }, + }); + + span.setAttribute("mcp.new_version", newWorkflow.version); + + await auditLog({ + action: "update", + resourceType: "workflow", + resourceId: newWorkflow.id, + projectId: context.projectId, + orgId: context.orgId, + apiKeyId: context.apiKeyId, + before: existingWorkflow ?? undefined, + after: newWorkflow, + }); + + return { + id: newWorkflow.id, + name: newWorkflow.name, + version: newWorkflow.version, + description: newWorkflow.description, + tags: newWorkflow.tags, + createdAt: newWorkflow.createdAt, + createdBy: newWorkflow.createdBy, + message: `Successfully updated workflow '${newWorkflow.name}' to version ${newWorkflow.version}`, + }; + }, + ); + }, +}); diff --git a/web/src/features/mcp/features/workflows/validation.ts b/web/src/features/mcp/features/workflows/validation.ts new file mode 100644 index 000000000000..a65ebf8cea12 --- /dev/null +++ b/web/src/features/mcp/features/workflows/validation.ts @@ -0,0 +1,27 @@ +/** + * Workflows Feature Validation Schemas + * + * Zod v4 schemas specific to the workflows feature domain. + * Common cross-feature validations live in /core/validation.ts + */ + +import { z } from "zod/v4"; + +/** + * Workflow name parameter + */ +export const ParamWorkflowName = z + .string() + .min(1) + .describe("The name of the workflow"); + +/** + * Workflow version parameter (optional) + * Must be a positive integer + */ +export const ParamWorkflowVersion = z.coerce + .number() + .int() + .positive() + .optional() + .describe("Specific version number to retrieve (e.g., 1, 2, 3)"); diff --git a/web/src/features/mcp/server/bootstrap.ts b/web/src/features/mcp/server/bootstrap.ts index acc4ecb741ca..1cfa2df0f61f 100644 --- a/web/src/features/mcp/server/bootstrap.ts +++ b/web/src/features/mcp/server/bootstrap.ts @@ -12,6 +12,7 @@ import { toolRegistry } from "./registry"; import { promptsFeature } from "../features/prompts"; +import { workflowsFeature } from "../features/workflows"; // Import future features as they're added: // import { datasetsFeature } from "../features/datasets"; // import { tracesFeature } from "../features/traces"; @@ -26,6 +27,7 @@ import { promptsFeature } from "../features/prompts"; export function bootstrapMcpFeatures(): void { // Register all feature modules toolRegistry.register(promptsFeature); + toolRegistry.register(workflowsFeature); // Add future features here: // toolRegistry.register(datasetsFeature); diff --git a/web/src/features/mcp/server/transport.ts b/web/src/features/mcp/server/transport.ts index a2c0d31e3744..517c85ec616a 100644 --- a/web/src/features/mcp/server/transport.ts +++ b/web/src/features/mcp/server/transport.ts @@ -1,14 +1,14 @@ /** * MCP Streamable HTTP Transport * - * Implements the Streamable HTTP transport for the Model Context Protocol (2025-03-26 spec). + * Implements Streamable HTTP transport for the Model Context Protocol (2025-03-26 spec). * This transport allows MCP communication over HTTP with JSON-RPC messages. * */ import { type NextApiRequest, type NextApiResponse } from "next"; import { type Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { formatErrorForUser } from "../core/error-formatting"; import { logger } from "@langfuse/shared/src/server"; @@ -18,9 +18,9 @@ import { logger } from "@langfuse/shared/src/server"; * This function: * 1. Sets CORS headers for MCP clients * 2. Creates a StreamableHTTPServerTransport (stateless mode) - * 3. Connects the server to the transport - * 4. Routes the request to the transport handler - * 5. Transport handles the response lifecycle + * 3. Connects to server to transport + * 4. Routes to request to the transport handler + * 5. Transport handles to response lifecycle * * Supports: * - POST: JSON-RPC requests (initialize, tool calls, etc.) @@ -61,41 +61,61 @@ export async function handleMcpRequest( } } - // Create Streamable HTTP transport (stateless mode - no sessionIdGenerator) - const transport = new StreamableHTTPServerTransport({ + // Create WebStandard HTTP transport (stateless mode - no sessionIdGenerator) + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, // Stateless mode enableJsonResponse: true, // Use JSON response (simpler for stateless mode) - enableDnsRebindingProtection: true, // CVE-2025-66414: Protect against DNS rebinding attacks }); - try { - // Connect server to transport - await server.connect(transport); + // Connect server to transport + await server.connect(transport); - logger.info("MCP server connected via Streamable HTTP transport", { - method: req.method, - }); + logger.info("MCP server connected via Web Standard HTTP transport", { + method: req.method, + }); - // Handle the request through the transport - // IMPORTANT: The transport manages the response lifecycle internally. - // It will send the response and end it when appropriate. - // Do NOT call res.end() after this - the transport handles it. - await transport.handleRequest(req, res, req.body); + // Convert Next.js IncomingMessage to Web Standard Request + const protocol = req.headers["x-forwarded-proto"] || "http"; + const host = req.headers.host || "localhost"; + const url = `${protocol}://${host}${req.url}`; - // Note: Do NOT end the response here. The transport has already - // sent the response (JSON or SSE) and ended it appropriately. - } finally { - // Clean up server and transport to prevent memory leaks - // server.close() internally calls transport.close() - await server.close().catch((err) => { - logger.warn("Error closing MCP server", { - error: err instanceof Error ? err.message : "Unknown", - }); - }); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) headers.append(key, v); + } else { + headers.set(key, value); + } } + + const isBodyMethod = + req.method !== "GET" && req.method !== "DELETE" && req.method !== "HEAD"; + const body = + isBodyMethod && req.body ? JSON.stringify(req.body) : undefined; + + const webRequest = new Request(url, { + method: req.method, + headers, + body, + }); + + // handleRequest returns a Web Standard Response + const webResponse = await transport.handleRequest(webRequest, { + parsedBody: req.body, + }); + + // Write the Web Standard Response back to Next.js ServerResponse + res.status(webResponse.status); + webResponse.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + const responseBody = await webResponse.text(); + res.end(responseBody); } catch (error) { logger.error("MCP transport error", { - message: error instanceof Error ? error.message : "Unknown error", + message: error instanceof Error ? error.message : "Unknown", name: error instanceof Error ? error.name : typeof error, method: req.method, }); diff --git a/web/src/features/rbac/constants/projectAccessRights.ts b/web/src/features/rbac/constants/projectAccessRights.ts index a69a470e69e9..19f4602e387a 100644 --- a/web/src/features/rbac/constants/projectAccessRights.ts +++ b/web/src/features/rbac/constants/projectAccessRights.ts @@ -64,6 +64,9 @@ export const projectScopes = [ "llmTools:CUD", "llmTools:read", + "workflows:CUD", + "workflows:read", + "comments:CUD", "comments:read", @@ -119,6 +122,8 @@ export const projectRoleAccessRights: Record = { "llmSchemas:read", "llmTools:CUD", "llmTools:read", + "workflows:CUD", + "workflows:read", "batchExports:create", "batchExports:read", "comments:CUD", @@ -172,6 +177,8 @@ export const projectRoleAccessRights: Record = { "llmSchemas:read", "llmTools:CUD", "llmTools:read", + "workflows:CUD", + "workflows:read", "batchExports:create", "batchExports:read", "comments:CUD", @@ -213,6 +220,8 @@ export const projectRoleAccessRights: Record = { "llmApiKeys:read", "llmSchemas:read", "llmTools:read", + "workflows:CUD", + "workflows:read", "batchExports:create", "batchExports:read", "comments:CUD", @@ -239,6 +248,7 @@ export const projectRoleAccessRights: Record = { "llmApiKeys:read", "llmSchemas:read", "llmTools:read", + "workflows:read", "comments:read", "annotationQueues:read", "promptExperiments:read", diff --git a/web/src/features/workflow-editor/components/WorkflowCanvas.tsx b/web/src/features/workflow-editor/components/WorkflowCanvas.tsx new file mode 100644 index 000000000000..8678db717a5b --- /dev/null +++ b/web/src/features/workflow-editor/components/WorkflowCanvas.tsx @@ -0,0 +1,164 @@ +import { useCallback, useMemo, useEffect } from "react"; +import { + ReactFlow, + Background, + Controls, + MiniMap, + addEdge, + useNodesState, + useEdgesState, + type OnConnect, + type NodeTypes, + type EdgeTypes, + type NodeChange, + type EdgeChange, + BackgroundVariant, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { AgentNode } from "./nodes/AgentNode"; +import { InputNode } from "./nodes/InputNode"; +import { OutputNode } from "./nodes/OutputNode"; +import { RouterNode } from "./nodes/RouterNode"; +import { CustomEdge } from "./edges/CustomEdge"; +import { wouldCreateCycle } from "../utils/graphValidation"; +import type { WorkflowNode, WorkflowEdge } from "../types"; + +interface WorkflowCanvasProps { + initialNodes?: WorkflowNode[]; + initialEdges?: WorkflowEdge[]; + onNodesChange?: (nodes: WorkflowNode[]) => void; + onEdgesChange?: (edges: WorkflowEdge[]) => void; + onNodeClick?: (event: React.MouseEvent, node: WorkflowNode) => void; + onPaneClick?: () => void; +} + +export function WorkflowCanvas({ + initialNodes = [], + initialEdges = [], + onNodesChange, + onEdgesChange, + onNodeClick, + onPaneClick, +}: WorkflowCanvasProps) { + const [nodes, setNodes, onNodesChangeInternal] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChangeInternal] = useEdgesState(initialEdges); + + // Sync internal state with props when they change + useEffect(() => { + setNodes(initialNodes); + }, [initialNodes, setNodes]); + + useEffect(() => { + setEdges(initialEdges); + }, [initialEdges, setEdges]); + + // Notify parent of changes + const handleNodesChange = useCallback( + (changes: NodeChange[]) => { + onNodesChangeInternal(changes); + // Get the updated nodes directly after the state update + if (onNodesChange) { + setNodes((prev) => { + onNodesChange(prev); + return prev; + }); + } + }, + [onNodesChangeInternal, onNodesChange, setNodes], + ); + + const handleEdgesChange = useCallback( + (changes: EdgeChange[]) => { + onEdgesChangeInternal(changes); + // Get the updated edges directly after the state update + if (onEdgesChange) { + setEdges((prev) => { + onEdgesChange(prev); + return prev; + }); + } + }, + [onEdgesChangeInternal, onEdgesChange, setEdges], + ); + + // Handle new connections with cycle detection + const onConnect: OnConnect = useCallback( + (connection) => { + if (!connection.source || !connection.target) return; + + const newEdge = { + source: connection.source, + target: connection.target, + }; + + // Check for cycles + if (wouldCreateCycle(nodes, edges, newEdge)) { + // TODO: Add toast notification when toast system is available + console.warn( + "Invalid connection: This connection would create a cycle in the workflow.", + ); + return; + } + + setEdges((eds) => { + const updatedEdges = addEdge(connection, eds); + if (onEdgesChange) { + onEdgesChange(updatedEdges); + } + return updatedEdges; + }); + }, + [nodes, edges, setEdges, onEdgesChange], + ); + + // Define custom node types + const nodeTypes: NodeTypes = useMemo( + () => ({ + agent: AgentNode, + input: InputNode, + output: OutputNode, + router: RouterNode, + }), + [], + ); + + // Define custom edge types + const edgeTypes: EdgeTypes = useMemo( + () => ({ + default: CustomEdge, + }), + [], + ); + + return ( +
+ + + + + +
+ ); +} diff --git a/web/src/features/workflow-editor/components/WorkflowListPage.tsx b/web/src/features/workflow-editor/components/WorkflowListPage.tsx new file mode 100644 index 000000000000..86fba2079a01 --- /dev/null +++ b/web/src/features/workflow-editor/components/WorkflowListPage.tsx @@ -0,0 +1,108 @@ +import { useRouter } from "next/router"; +import Header from "@/src/components/layouts/header"; +import { Button } from "@/src/components/ui/button"; +import { Plus, Play } from "lucide-react"; +import { api } from "@/src/utils/api"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/src/components/ui/table"; +import { formatDistanceToNow } from "date-fns"; + +export function WorkflowListPage() { + const router = useRouter(); + const projectId = router.query.projectId as string; + + const { data: workflows, isLoading } = api.workflows.getAll.useQuery( + { projectId }, + { enabled: Boolean(projectId) }, + ); + + const handleCreateNew = () => { + void router.push(`/project/${projectId}/workflows/new`); + }; + + const handleOpenWorkflow = (workflowId: string) => { + void router.push(`/project/${projectId}/workflows/${workflowId}`); + }; + + return ( +
+
+ + New Workflow + + } + /> +
+ {isLoading ? ( +
+

Loading workflows...

+
+ ) : workflows && workflows.length > 0 ? ( + + + + Name + Description + Last Modified + Created + Actions + + + + {workflows.map((workflow) => ( + handleOpenWorkflow(workflow.id)} + > + {workflow.name} + + {workflow.description || "—"} + + + {formatDistanceToNow(new Date(workflow.updatedAt), { + addSuffix: true, + })} + + + {formatDistanceToNow(new Date(workflow.createdAt), { + addSuffix: true, + })} + + e.stopPropagation()}> + + + + ))} + +
+ ) : ( +
+

+ No workflows yet. Create your first workflow to get started. +

+ +
+ )} +
+
+ ); +} diff --git a/web/src/features/workflow-editor/components/dialogs/PromptImportDialog.tsx b/web/src/features/workflow-editor/components/dialogs/PromptImportDialog.tsx new file mode 100644 index 000000000000..df227609eced --- /dev/null +++ b/web/src/features/workflow-editor/components/dialogs/PromptImportDialog.tsx @@ -0,0 +1,291 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/src/components/ui/dialog"; +import { Button } from "@/src/components/ui/button"; +import { Input } from "@/src/components/ui/input"; +import { Loader2, FileText, Search } from "lucide-react"; +import { cn } from "@/src/utils/tailwind"; +import { api } from "@/src/utils/api"; +import { z } from "zod/v4"; +import type { PromptChatMessageSchema } from "@langfuse/shared"; + +type PromptMessage = z.infer; + +interface PromptImportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: string; + onImport: (promptData: { + messages: PromptMessage[]; + modelParams: Record; + }) => void; +} + +export function PromptImportDialog({ + open, + onOpenChange, + projectId, + onImport, +}: PromptImportDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedPromptId, setSelectedPromptId] = useState(null); + + // Fetch all prompt names + const { data: promptNames, isLoading: isLoadingNames } = + api.prompts.allPromptMeta.useQuery( + { projectId }, + { enabled: open && Boolean(projectId) }, + ); + + // Fetch full prompt data when a prompt is selected + const { data: fullPrompt, isLoading: isLoadingPrompt } = + api.prompts.byId.useQuery( + { + projectId, + id: selectedPromptId ?? "", + }, + { + enabled: Boolean(selectedPromptId && projectId), + }, + ); + + // Filter prompts based on search query + const filteredPrompts = + promptNames?.filter((prompt) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + prompt.name.toLowerCase().includes(query) || + prompt.labels.some((label) => label.toLowerCase().includes(query)) || + prompt.version.toString().includes(query) + ); + }) ?? []; + + const handleImport = () => { + if (!fullPrompt) return; + + // Only support chat prompts for now + if (fullPrompt.type !== "chat") { + return; + } + + // Parse the prompt as an array of messages + const messages = Array.isArray(fullPrompt.prompt) + ? (fullPrompt.prompt as PromptMessage[]) + : []; + + onImport({ + messages, + modelParams: (fullPrompt.config as Record) ?? {}, + }); + + onOpenChange(false); + setSelectedPromptId(null); + setSearchQuery(""); + }; + + const handleSelectPrompt = (promptId: string) => { + setSelectedPromptId(promptId); + }; + + const handleBack = () => { + setSelectedPromptId(null); + }; + + return ( + + + + Import Prompt + + Select a prompt to import its messages and configuration + + + + {selectedPromptId ? ( + // Detail view for selected prompt +
+ {isLoadingPrompt ? ( +
+ + + Loading prompt details... + +
+ ) : fullPrompt ? ( + <> +
+
+

{fullPrompt.name}

+

+ Version {fullPrompt.version} +

+
+ + {fullPrompt.labels.length > 0 && ( +
+ {fullPrompt.labels.map((label) => ( + + {label} + + ))} +
+ )} + +
+

Type:

+

+ {fullPrompt.type} +

+
+ + {fullPrompt.type === "chat" && ( +
+

Messages:

+

+ {Array.isArray(fullPrompt.prompt) + ? fullPrompt.prompt.length + : 0}{" "} + message(s) +

+
+ )} + + {fullPrompt.config && + Object.keys(fullPrompt.config as object).length > 0 && ( +
+

Configuration:

+
+                          {JSON.stringify(fullPrompt.config, null, 2)}
+                        
+
+ )} +
+ + {fullPrompt.type !== "chat" && ( +
+ Only chat prompts can be imported into workflow nodes. +
+ )} + +
+ + +
+ + ) : ( +
+

Prompt not found

+
+ )} +
+ ) : ( + // List view + <> +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ {isLoadingNames ? ( +
+ + + Loading prompts... + +
+ ) : filteredPrompts.length === 0 ? ( +
+ +

+ {searchQuery + ? "No prompts match your search" + : "No prompts found"} +

+

+ {searchQuery + ? "Try a different search term" + : "Create prompts in the Prompts section"} +

+
+ ) : ( +
+ {filteredPrompts.map((prompt) => ( + +
+ + ))} +
+ )} + + + )} +
+
+ ); +} diff --git a/web/src/features/workflow-editor/components/dialogs/WorkflowLoadDialog.tsx b/web/src/features/workflow-editor/components/dialogs/WorkflowLoadDialog.tsx new file mode 100644 index 000000000000..2824044aaea6 --- /dev/null +++ b/web/src/features/workflow-editor/components/dialogs/WorkflowLoadDialog.tsx @@ -0,0 +1,125 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/src/components/ui/dialog"; +import { Button } from "@/src/components/ui/button"; +import { Loader2, FileText, Calendar } from "lucide-react"; +import { cn } from "@/src/utils/tailwind"; + +interface Workflow { + id: string; + name: string; + description: string; + version: number; + updatedAt: Date; + tags: string[]; +} + +interface WorkflowLoadDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workflows: Workflow[]; + isLoading: boolean; + onLoad: (workflowId: string) => Promise; +} + +export function WorkflowLoadDialog({ + open, + onOpenChange, + workflows, + isLoading, + onLoad, +}: WorkflowLoadDialogProps) { + const handleLoad = async (workflowId: string) => { + await onLoad(workflowId); + onOpenChange(false); + }; + + return ( + + + + Load Workflow + + Select a workflow to load into the editor + + + +
+ {isLoading ? ( +
+ + + Loading workflows... + +
+ ) : workflows.length === 0 ? ( +
+ +

No workflows found

+

+ Save your first workflow to see it here +

+
+ ) : ( +
+ {workflows.map((workflow) => ( + +
+ + ))} +
+ )} + +
+
+ ); +} diff --git a/web/src/features/workflow-editor/components/dialogs/WorkflowOutputDialog.tsx b/web/src/features/workflow-editor/components/dialogs/WorkflowOutputDialog.tsx new file mode 100644 index 000000000000..32c17723a864 --- /dev/null +++ b/web/src/features/workflow-editor/components/dialogs/WorkflowOutputDialog.tsx @@ -0,0 +1,110 @@ +import { useTheme } from "next-themes"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogBody, +} from "@/src/components/ui/dialog"; +import { default as React18JsonView } from "react18-json-view"; +import "react18-json-view/src/dark.css"; +import { FileText } from "lucide-react"; +import { cn } from "@/src/utils/tailwind"; +import type { WorkflowResult } from "../../types"; + +interface WorkflowOutputDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + results: WorkflowResult[]; +} + +export function WorkflowOutputDialog({ + open, + onOpenChange, + results, +}: WorkflowOutputDialogProps) { + const { resolvedTheme } = useTheme(); + + const parseOutput = (output: string): unknown => { + try { + return JSON.parse(output); + } catch { + return output; + } + }; + + return ( + + + + Workflow Execution Results + + View the output from each node in the workflow execution + + + + + {results.length === 0 ? ( +
+ +

No results available

+

+ Execute the workflow to see results here +

+
+ ) : ( +
+ {results.map((result, index) => { + const parsedOutput = parseOutput(result.output); + const isJsonOutput = typeof parsedOutput === "object"; + + return ( +
+
+

+ Node: {result.nodeId} +

+
+
+ {isJsonOutput ? ( +
+ +
+ ) : ( + + {result.output} + + )} +
+
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/web/src/features/workflow-editor/components/dialogs/WorkflowSaveDialog.tsx b/web/src/features/workflow-editor/components/dialogs/WorkflowSaveDialog.tsx new file mode 100644 index 000000000000..88baa708797a --- /dev/null +++ b/web/src/features/workflow-editor/components/dialogs/WorkflowSaveDialog.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/src/components/ui/dialog"; +import { Button } from "@/src/components/ui/button"; +import { Input } from "@/src/components/ui/input"; +import { Label } from "@/src/components/ui/label"; +import { Textarea } from "@/src/components/ui/textarea"; + +interface WorkflowSaveDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (name: string, description: string) => Promise; + isSaving: boolean; +} + +export function WorkflowSaveDialog({ + open, + onOpenChange, + onSave, + isSaving, +}: WorkflowSaveDialogProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + + const handleSave = async () => { + if (!name.trim()) { + setError("Workflow name is required"); + return; + } + + try { + setError(null); + await onSave(name.trim(), description.trim()); + // Reset form + setName(""); + setDescription(""); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save workflow"); + } + }; + + return ( + + + + Save Workflow + + Save your workflow to the database. You can load it later or share + it with your team. + + + +
+
+ + setName(e.target.value)} + disabled={isSaving} + /> +
+ +
+ +