diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 1602334f8..98abd1f50 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -54,6 +54,7 @@ "@decocms/vite-plugin": "workspace:*", "@hookform/resolvers": "^5.2.2", "@modelcontextprotocol/sdk": "1.20.2", + "@monaco-editor/react": "^4.7.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-prometheus": "^0.208.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.207.0", @@ -76,6 +77,11 @@ "@tanstack/react-query": "^5.90.11", "@tanstack/react-router": "^1.139.7", "@tanstack/react-router-devtools": "^1.139.7", + "@tiptap/extension-mention": "^3.13.0", + "@tiptap/pm": "^3.13.0", + "@tiptap/react": "^3.13.0", + "@tiptap/starter-kit": "^3.13.0", + "@tiptap/suggestion": "^3.13.0", "@types/bun": "^1.3.1", "@types/pg": "^8.15.6", "@types/react": "^19.2.2", @@ -97,6 +103,8 @@ "hono": "^4.10.7", "idb-keyval": "^6.2.2", "input-otp": "^1.4.2", + "lucide-react": "^0.468.0", + "prettier": "^3.4.2", "jose": "^6.0.11", "nanoid": "^5.1.6", "pg": "^8.16.3", @@ -113,7 +121,8 @@ "vite-tsconfig-paths": "^5.1.4", "zod": "^3.25.76", "zod-from-json-schema": "0.0.5", - "zod-to-json-schema": "3.25.0" + "zod-to-json-schema": "3.25.0", + "zustand": "^5.0.9" }, "module": "src/index.ts", "keywords": [ @@ -128,4 +137,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/apps/mesh/src/auth/configuration-scopes.ts b/apps/mesh/src/auth/configuration-scopes.ts index 604eefcd2..2eff01cc9 100644 --- a/apps/mesh/src/auth/configuration-scopes.ts +++ b/apps/mesh/src/auth/configuration-scopes.ts @@ -81,6 +81,10 @@ export function extractConnectionPermissions( } for (const scope of scopes) { + if (scope === '*') { + permissions['*'] = ['*']; + continue; + } const parsed = tryParseScope(scope); if (!parsed) continue; diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index 4277998e2..bab7a0201 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -224,6 +224,9 @@ const plugins = [ ], }, }, + rateLimit: { + enabled: false, + }, }), // Admin plugin for system-level super-admins diff --git a/apps/mesh/src/storage/event-bus.ts b/apps/mesh/src/storage/event-bus.ts index 38de40961..891dde719 100644 --- a/apps/mesh/src/storage/event-bus.ts +++ b/apps/mesh/src/storage/event-bus.ts @@ -1002,7 +1002,7 @@ class KyselyEventBusStorage implements EventBusStorage { organization_id: organizationId, connection_id: connectionId, event_type: desiredSub.eventType, - publisher: desiredSub.publisher ?? null, + publisher: connectionId ?? desiredSub.publisher ?? null, filter: desiredSub.filter ?? null, enabled: 1, created_at: now, diff --git a/apps/mesh/src/tools/connection/update.ts b/apps/mesh/src/tools/connection/update.ts index e345df660..b164c452c 100644 --- a/apps/mesh/src/tools/connection/update.ts +++ b/apps/mesh/src/tools/connection/update.ts @@ -50,10 +50,14 @@ async function validateConfiguration( // Validate scope format and state keys for (const scope of scopes) { // Parse scope format: "KEY::SCOPE" (throws on invalid format) + if (scope === '*') { + continue; + } const [key] = parseScope(scope); const value = prop(key, state); // Check if this key exists in state + if (value === undefined || value === null) { throw new Error( `Scope references key "${key}" but it's not present in state`, diff --git a/apps/mesh/src/tools/database/index.ts b/apps/mesh/src/tools/database/index.ts index e8aebcee0..2dc35079c 100644 --- a/apps/mesh/src/tools/database/index.ts +++ b/apps/mesh/src/tools/database/index.ts @@ -9,6 +9,76 @@ const QueryResult = z.object({ success: z.boolean().optional(), }); +/** + * Safely escape and quote SQL values + * This is still not as safe as parameterized queries, but better than raw replacement + */ +function escapeSqlValue(value: any): string { + if (value === null || value === undefined) { + return "NULL"; + } + + if (typeof value === "number") { + return String(value); + } + + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE"; + } + + if (typeof value === "string") { + // Escape single quotes by doubling them (SQL standard) + // and wrap in quotes + return `'${value.replace(/'/g, "''")}'`; + } + + if (value instanceof Date) { + return `'${value.toISOString()}'`; + } + + // For arrays, objects, etc - serialize to JSON string + return `'${JSON.stringify(value).replace(/'/g, "''")}'`; +} + +/** + * Replace ALL placeholders (?, $1, $2, etc.) with escaped values + * + * IMPORTANT: We find all placeholder positions FIRST, then replace from end to start. + * This prevents ? characters inside interpolated values from being treated as placeholders. + */ +function interpolateParams(sql: string, params: any[]): string { + // First, handle $1, $2, etc. style placeholders (unambiguous) + let result = sql; + for (let i = params.length; i >= 1; i--) { + const placeholder = `$${i}`; + if (result.includes(placeholder)) { + result = result.replaceAll(placeholder, escapeSqlValue(params[i - 1])); + } + } + + // For ? placeholders, find all positions FIRST, then replace from end to start + // This prevents ? inside interpolated values from being matched + const questionMarkPositions: number[] = []; + for (let i = 0; i < result.length; i++) { + if (result[i] === "?") { + questionMarkPositions.push(i); + } + } + + // Replace from end to start so positions don't shift + for ( + let i = Math.min(questionMarkPositions.length, params.length) - 1; + i >= 0; + i-- + ) { + const pos = questionMarkPositions[i]; + const escaped = escapeSqlValue(params[i]); + result = result.slice(0, pos!) + escaped + result.slice(pos! + 1); + } + + return result; +} + export type QueryResult = z.infer; const DatatabasesRunSqlInputSchema = z.object({ @@ -136,14 +206,13 @@ export const DATABASES_RUN_SQL = defineTool({ description: "Run a SQL query against the database", inputSchema: DatatabasesRunSqlInputSchema, - outputSchema: z.lazy(() => - z.object({ - result: z.array(QueryResult), - }), - ), + outputSchema: z.object({ + result: z.array(QueryResult), + }), handler: async (input, ctx) => { requireAuth(ctx); await ctx.access.check(); + const sqlQuery = interpolateParams(input.sql, input.params || []); if (!ctx.connectionId) { throw new Error("Connection context required for database access"); @@ -152,15 +221,6 @@ export const DATABASES_RUN_SQL = defineTool({ const schemaName = getSchemaName(ctx.connectionId); const roleName = getRoleName(ctx.connectionId); - let sqlQuery = input.sql; - for (let i = 0; i < (input.params?.length ?? 0); i++) { - const param = input.params?.[i]; - sqlQuery = sqlQuery.replace( - `?`, - typeof param === "string" ? `'${param}'` : `${param}`, - ); - } - const result = await executeWithIsolation( ctx.db, schemaName, diff --git a/apps/mesh/src/web/components/details/layout.tsx b/apps/mesh/src/web/components/details/layout.tsx index 974ae7683..d33193f35 100644 --- a/apps/mesh/src/web/components/details/layout.tsx +++ b/apps/mesh/src/web/components/details/layout.tsx @@ -1,75 +1,82 @@ import { Button } from "@deco/ui/components/button.tsx"; import { ArrowLeft } from "@untitledui/icons"; -import { type ReactNode, useEffect, useState } from "react"; +import { createContext, type ReactNode, useContext, useState } from "react"; import { createPortal } from "react-dom"; -const TABS_PORTAL_ID = "view-details-tabs-portal"; -const ACTIONS_PORTAL_ID = "view-details-actions-portal"; - -interface PortalProps { - children: ReactNode; +interface ViewLayoutContextValue { + tabsEl: HTMLDivElement | null; + actionsEl: HTMLDivElement | null; } -function usePortal(id: string) { - const [element, setElement] = useState(null); +const ViewLayoutContext = createContext(null); - // oxlint-disable-next-line ban-use-effect/ban-use-effect - useEffect(() => { - setElement(document.getElementById(id)); - }, [id]); - - return element; +interface PortalProps { + children: ReactNode; + icon?: string; + title?: string; } export function ViewTabs({ children }: PortalProps) { - const target = usePortal(TABS_PORTAL_ID); - if (!target) return null; - return createPortal(children, target); + const ctx = useContext(ViewLayoutContext); + if (!ctx?.tabsEl) return null; + return createPortal(children, ctx.tabsEl); } export function ViewActions({ children }: PortalProps) { - const target = usePortal(ACTIONS_PORTAL_ID); - if (!target) return null; - return createPortal(children, target); + const ctx = useContext(ViewLayoutContext); + if (!ctx?.actionsEl) return null; + return createPortal(children, ctx.actionsEl); } interface ViewLayoutProps { children: ReactNode; onBack: () => void; + title?: string; } -export function ViewLayout({ children, onBack }: ViewLayoutProps) { +export function ViewLayout({ children, onBack, title }: ViewLayoutProps) { + const [tabsEl, setTabsEl] = useState(null); + const [actionsEl, setActionsEl] = useState(null); + return ( -
- {/* Header */} -
- {/* Back Button */} -
- -
+ +
+ {/* Header */} +
+ {/* Back Button */} +
+ +
+ + {title && ( +
+

{title}

+
+ )} - {/* Tabs and Actions */} -
- {/* Tabs Slot */} -
+ {/* Tabs and Actions */} +
+ {/* Tabs Slot */} +
- {/* Actions Slot */} -
+ {/* Actions Slot */} +
+
-
- {/* Main Content */} -
{children}
-
+ {/* Main Content */} +
{children}
+
+ ); } diff --git a/apps/mesh/src/web/components/details/workflow/components/executions-list.tsx b/apps/mesh/src/web/components/details/workflow/components/executions-list.tsx new file mode 100644 index 000000000..565113a48 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/executions-list.tsx @@ -0,0 +1,204 @@ +import { useState } from "react"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + X, + SearchMd, + Check, + AlertOctagon, + Columns01, + DotsHorizontal, +} from "@untitledui/icons"; +import type { WorkflowExecution } from "@decocms/bindings/workflow"; +import { useWorkflowExecutions } from "../hooks/queries/use-workflow-executions"; +import { useViewModeStore } from "../stores/view-mode"; +import { useTrackingExecutionId, useWorkflowActions } from "../stores/workflow"; + +interface ExecutionsListProps { + className?: string; +} + +type ExecutionStatus = WorkflowExecution["status"]; + +function getStatusBadge(status: ExecutionStatus) { + switch (status) { + case "success": + return ( + + + Success + + ); + case "error": + return ( + + + Error + + ); + case "running": + return ( + + + Running + + ); + case "enqueued": + return ( + + + On hold + + ); + case "cancelled": + return ( + + + Cancelled + + ); + default: + return ( + + + {status} + + ); + } +} + +function formatExecutionId(id: string): string { + // Take last 4 characters of the ID for display + const shortId = id.slice(-4).toUpperCase(); + return `Run #${shortId}`; +} + +export function ExecutionsList({ className }: ExecutionsListProps) { + const { executions, isLoading } = useWorkflowExecutions(); + const { setShowExecutionsList } = useViewModeStore(); + const { setTrackingExecutionId } = useWorkflowActions(); + const trackingExecutionId = useTrackingExecutionId(); + const [searchQuery, setSearchQuery] = useState(""); + + const filteredExecutions = executions.filter((execution) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + execution.id.toLowerCase().includes(query) || + execution.status.toLowerCase().includes(query) + ); + }); + + const handleSelectExecution = (executionId: string) => { + setTrackingExecutionId(executionId); + }; + + const handleClose = () => { + setShowExecutionsList(false); + }; + + return ( +
+ {/* Header */} +
+

Runs

+ +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="flex-1 border-0 bg-transparent h-full text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 shadow-none px-0" + /> +
+ + {/* Executions List */} +
+ {isLoading ? ( +
+ Loading executions... +
+ ) : filteredExecutions.length === 0 ? ( +
+ {searchQuery ? "No matching runs found" : "No runs yet"} +
+ ) : ( + filteredExecutions.map((execution, index) => ( + handleSelectExecution(execution.id)} + /> + )) + )} +
+
+ ); +} + +interface ExecutionRowProps { + execution: WorkflowExecution; + isSelected: boolean; + isFirst: boolean; + onSelect: () => void; +} + +function ExecutionRow({ + execution, + isSelected, + isFirst, + onSelect, +}: ExecutionRowProps) { + return ( +
+
+ {getStatusBadge(execution.status)} +

+ {formatExecutionId(execution.id)} +

+ {isSelected && ( + + )} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/workflow/components/monaco-editor.tsx b/apps/mesh/src/web/components/details/workflow/components/monaco-editor.tsx new file mode 100644 index 000000000..13446de11 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/monaco-editor.tsx @@ -0,0 +1,312 @@ +import { memo, useRef, useId, Component, cloneElement } from "react"; +import Editor, { + loader, + OnMount, + type EditorProps, +} from "@monaco-editor/react"; +import type { Plugin } from "prettier"; +import { Spinner } from "@deco/ui/components/spinner.js"; +import { getReturnType } from "./monaco"; + +// ============================================ +// Types +// ============================================ + +interface MonacoCodeEditorProps { + code: string; + onChange?: (value: string | undefined) => void; + onSave?: ( + value: string, + outputSchema: Record | null, + ) => void; + readOnly?: boolean; + height?: string | number; + language?: "typescript" | "json"; + foldOnMount?: boolean; +} + +// Internal component that receives mountKey from error boundary +interface InternalEditorProps extends MonacoCodeEditorProps { + mountKey?: number; +} + +// Error boundary to catch Monaco disposal errors and recover by forcing remount +class MonacoErrorBoundary extends Component< + { children: React.ReactElement }, + { hasError: boolean; mountKey: number } +> { + constructor(props: { children: React.ReactElement }) { + super(props); + this.state = { hasError: false, mountKey: 0 }; + } + + static getDerivedStateFromError(error: Error) { + // Check if it's the specific Monaco disposal error + if (error.message?.includes("InstantiationService has been disposed")) { + return { hasError: true }; + } + throw error; + } + + override componentDidCatch(error: Error) { + if (error.message?.includes("InstantiationService has been disposed")) { + // Schedule recovery: increment mountKey and clear error + this.setState((prev) => ({ + hasError: false, + mountKey: prev.mountKey + 1, + })); + } + } + + override render() { + if (this.state.hasError) { + return ( +
+ +
+ ); + } + // Clone child with mountKey to force fresh instance on recovery + return cloneElement(this.props.children, { + mountKey: this.state.mountKey, + }); + } +} + +// Lazy load Prettier modules +let prettierCache: { + format: (code: string, options: object) => Promise; + plugins: Plugin[]; +} | null = null; + +const loadPrettier = async () => { + if (prettierCache) return prettierCache; + + const [prettierModule, tsPlugin, estreePlugin] = await Promise.all([ + import("prettier/standalone"), + import("prettier/plugins/typescript"), + import("prettier/plugins/estree"), + ]); + + prettierCache = { + format: prettierModule.format, + plugins: [tsPlugin.default, estreePlugin.default], + }; + + return prettierCache; +}; + +// Configure Monaco to load from CDN +loader.config({ + paths: { + vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs", + }, +}); + +// ============================================ +// Static Constants (module-scoped for stability) +// ============================================ + +const PRETTIER_OPTIONS = { + parser: "typescript", + semi: true, + singleQuote: false, + tabWidth: 2, + trailingComma: "es5", + printWidth: 80, +} as const; + +const EDITOR_BASE_OPTIONS: EditorProps["options"] = { + fontSize: 13, + lineNumbers: "on", + scrollBeyondLastLine: false, + automaticLayout: true, + foldingStrategy: "auto", + tabSize: 2, + wordWrap: "on", + folding: true, + bracketPairColorization: { enabled: true }, + formatOnPaste: true, + formatOnType: true, + suggestOnTriggerCharacters: true, + quickSuggestions: { + other: true, + comments: false, + strings: true, + }, + parameterHints: { enabled: true }, + inlineSuggest: { enabled: true }, + autoClosingBrackets: "always", + autoClosingQuotes: "always", + autoSurround: "languageDefined", + padding: { top: 12, bottom: 12 }, + scrollbar: { + vertical: "auto", + horizontal: "auto", + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, + theme: "light", +}; + +const LoadingPlaceholder = ( +
+ +
+); + +const InternalMonacoEditor = memo(function InternalMonacoEditor({ + code, + onChange, + onSave, + readOnly = false, + height = 300, + language = "typescript", + foldOnMount = false, + mountKey = 0, +}: InternalEditorProps) { + const editorRef = useRef[0] | null>(null); + const containerRef = useRef(null); + const onSaveRef = useRef(onSave); + onSaveRef.current = onSave; + + // Store language in ref to avoid stale closures in editor callbacks + const languageRef = useRef(language); + languageRef.current = language; + + // Unique path so Monaco treats this as a TypeScript file + const uniqueId = useId(); + const editorKey = `${uniqueId}-${mountKey}`; + const filePath = + language === "typescript" + ? `file:///workflow-${uniqueId.replace(/:/g, "-")}-${mountKey}.tsx` + : undefined; + + // Compute options with readOnly merged in + const editorOptions = readOnly + ? { ...EDITOR_BASE_OPTIONS, readOnly: true } + : EDITOR_BASE_OPTIONS; + + // Format function that uses refs to avoid stale closures + const formatWithPrettier = async (editorInstance: Parameters[0]) => { + const model = editorInstance.getModel(); + if (!model) { + console.warn("No model found"); + return; + } + + const currentCode = model.getValue(); + const currentLanguage = languageRef.current; + + // For JSON, use native JSON formatting + if (currentLanguage === "json") { + try { + const parsed = JSON.parse(currentCode); + const formatted = JSON.stringify(parsed, null, 2); + if (formatted !== currentCode) { + const fullRange = model.getFullModelRange(); + editorInstance.executeEdits("json-format", [ + { range: fullRange, text: formatted }, + ]); + } + } catch (err) { + console.error("JSON formatting failed:", err); + } + return; + } + + // For TypeScript, use Prettier + try { + const { format, plugins } = await loadPrettier(); + + const formatted = await format(currentCode, { + ...PRETTIER_OPTIONS, + plugins, + }); + + // Only update if the formatted code is different + if (formatted !== currentCode) { + const fullRange = model.getFullModelRange(); + editorInstance.executeEdits("prettier", [ + { range: fullRange, text: formatted }, + ]); + } + } catch (err) { + console.error("Prettier formatting failed:", err); + } + }; + + const handleEditorDidMount: OnMount = async (editor, monaco) => { + editorRef.current = editor; + + // Fold first level regions if requested, then reveal + if (foldOnMount && containerRef.current) { + containerRef.current.style.visibility = "hidden"; + await editor.getAction("editor.foldLevel2")?.run(); + containerRef.current.style.visibility = "visible"; + } + + // Configure TypeScript AFTER mount (beforeMount was causing value not to display) + if (language === "typescript") { + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.ESNext, + module: monaco.languages.typescript.ModuleKind.ESNext, + moduleResolution: + monaco.languages.typescript.ModuleResolutionKind.NodeJs, + allowNonTsExtensions: true, + allowJs: true, + strict: false, // Less strict for workflow code + noEmit: true, + esModuleInterop: true, + jsx: monaco.languages.typescript.JsxEmit.React, + allowSyntheticDefaultImports: true, + }); + + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + }); + } + + // Add Ctrl+S / Cmd+S keybinding to format and save + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, async () => { + // Format the document first + await formatWithPrettier(editor); + const returnType = await getReturnType(editor); + + // Then call onSave with the formatted value + const value = editor.getValue(); + + console.log({ value, returnType }); + + onSaveRef.current?.(value, returnType as Record | null); + }); + }; + + return ( +
+ +
+ ); +}); + +// Public component that wraps with error boundary for disposal recovery +export const MonacoCodeEditor = memo(function MonacoCodeEditor( + props: MonacoCodeEditorProps, +) { + return ( + + + + ); +}); diff --git a/apps/mesh/src/web/components/details/workflow/components/monaco/index.ts b/apps/mesh/src/web/components/details/workflow/components/monaco/index.ts new file mode 100644 index 000000000..515311e5c --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/monaco/index.ts @@ -0,0 +1,247 @@ +import type { OnMount } from "@monaco-editor/react"; +import { loader } from "@monaco-editor/react"; + +export async function getReturnType(editor: Parameters[0]) { + const model = editor.getModel(); + if (!model) { + throw new Error("[Monaco] No model found in editor"); + } + // Strategy: Append a helper type to the code and query its expanded type + const originalCode = model.getValue(); + + // Use a recursive Expand utility type to force TypeScript to inline all type references + const helperCode = ` +type __ExpandRecursively = T extends (...args: infer A) => infer R + ? (...args: __ExpandRecursively) => __ExpandRecursively + : T extends object + ? T extends infer O ? { [K in keyof O]: __ExpandRecursively } : never + : T; +type __InferredOutput = __ExpandRecursively>>; +declare const __outputValue: __InferredOutput; +`; + + // Replace "export default" with a named function temporarily + const modifiedCode = + originalCode.replace( + /export default (async )?function/, + "export default $1function __default", + ) + helperCode; + + // Set the modified code temporarily + model.setValue(modifiedCode); + + // Find the __outputValue declaration to get its type + const matches = model.findMatches( + "__outputValue", + false, + false, + false, + null, + false, + ); + + if (!matches || matches.length === 0) { + model.setValue(originalCode); + return null; + } + + const match = matches[0]; + if (!match) { + model.setValue(originalCode); + return null; + } + + const position = { + lineNumber: match.range.startLineNumber, + column: match.range.startColumn + 1, + }; + + try { + // Get the actual Monaco instance from the loader (not the React wrapper) + const monacoInstance = await loader.init(); + const worker = + await monacoInstance.languages.typescript.getTypeScriptWorker(); + if (!worker) { + model.setValue(originalCode); + return null; + } + const client = await worker(model.uri); + + // Wait for TypeScript to process the modified code + await new Promise((resolve) => setTimeout(resolve, 100)); + + const offset = model.getOffsetAt(position); + const quickInfo = await client.getQuickInfoAtPosition( + model.uri.toString(), + offset, + ); + + // Restore original code + model.setValue(originalCode); + + if (quickInfo) { + const displayString = quickInfo.displayParts + .map((part: { text: string }) => part.text) + .join(""); + + // Clean up the display string - remove "const __outputValue: " prefix + const typeOnly = displayString.replace(/^const __outputValue:\s*/, ""); + + // Convert to JSON Schema + const jsonSchema = tsTypeToJsonSchema(typeOnly); + + return jsonSchema; + } else { + return null; + } + } catch (error) { + model.setValue(originalCode); + console.error("Error getting return type:", error); + return null; + } +} + +function tsTypeToJsonSchema(typeStr: string): object { + typeStr = typeStr.trim(); + + // Handle primitives + if (typeStr === "string") return { type: "string" }; + if (typeStr === "number") return { type: "number" }; + if (typeStr === "boolean") return { type: "boolean" }; + if (typeStr === "null") return { type: "null" }; + if (typeStr === "undefined") return { type: "null" }; + if (typeStr === "unknown" || typeStr === "any") return {}; + if (typeStr === "never") return { not: {} }; + + // Handle arrays: T[] or Array + if (typeStr.endsWith("[]")) { + const itemType = typeStr.slice(0, -2); + return { type: "array", items: tsTypeToJsonSchema(itemType) }; + } + const arrayMatch = typeStr.match(/^Array<(.+)>$/); + if (arrayMatch && arrayMatch[1]) { + return { type: "array", items: tsTypeToJsonSchema(arrayMatch[1]) }; + } + + // Handle Record + const recordMatch = typeStr.match(/^Record<(.+),\s*(.+)>$/); + if (recordMatch && recordMatch[2]) { + return { + type: "object", + additionalProperties: tsTypeToJsonSchema(recordMatch[2].trim()), + }; + } + + // Handle union types: A | B | C + if (typeStr.includes("|") && !typeStr.startsWith("{")) { + const parts = splitUnion(typeStr); + // Check if it's a string literal union + const allStringLiterals = parts.every((p) => /^["']/.test(p.trim())); + if (allStringLiterals) { + return { + type: "string", + enum: parts.map((p) => p.trim().replace(/^["']|["']$/g, "")), + }; + } + return { anyOf: parts.map((p) => tsTypeToJsonSchema(p.trim())) }; + } + + // Handle string/number literals + if (/^["'].*["']$/.test(typeStr)) { + return { type: "string", const: typeStr.slice(1, -1) }; + } + if (/^-?\d+(\.\d+)?$/.test(typeStr)) { + return { type: "number", const: parseFloat(typeStr) }; + } + + // Handle object types: { prop: type; ... } + if (typeStr.startsWith("{") && typeStr.endsWith("}")) { + return parseObjectType(typeStr); + } + + // Fallback for complex types + return { description: `TypeScript type: ${typeStr}` }; +} + +// Split union types while respecting nested braces +function splitUnion(typeStr: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + + for (let i = 0; i < typeStr.length; i++) { + const char = typeStr[i]; + if (char === "{" || char === "<" || char === "(") depth++; + else if (char === "}" || char === ">" || char === ")") depth--; + else if (char === "|" && depth === 0) { + parts.push(current.trim()); + current = ""; + continue; + } + current += char; + } + if (current.trim()) parts.push(current.trim()); + return parts; +} + +// Parse object type: { prop: type; prop2?: type2; ... } +function parseObjectType(typeStr: string): object { + // Remove outer braces + const inner = typeStr.slice(1, -1).trim(); + if (!inner) return { type: "object", properties: {} }; + + const properties: Record = {}; + const required: string[] = []; + + // Parse properties - handle nested objects by tracking brace depth + let depth = 0; + let currentProp = ""; + + for (let i = 0; i < inner.length; i++) { + const char = inner[i]; + if (char === "{" || char === "<" || char === "(" || char === "[") depth++; + else if (char === "}" || char === ">" || char === ")" || char === "]") + depth--; + else if (char === ";" && depth === 0) { + if (currentProp.trim()) { + parseSingleProperty(currentProp.trim(), properties, required); + } + currentProp = ""; + continue; + } + currentProp += char; + } + // Handle last property (may not end with ;) + if (currentProp.trim()) { + parseSingleProperty(currentProp.trim(), properties, required); + } + + const schema: Record = { + type: "object", + properties, + }; + if (required.length > 0) { + schema.required = required; + } + return schema; +} + +function parseSingleProperty( + propStr: string, + properties: Record, + required: string[], +) { + // Match: propName?: type or propName: type + const match = propStr.match(/^(\w+)(\?)?:\s*(.+)$/s); + if (match) { + const propName = match[1]; + const optional = match[2]; + const propType = match[3]; + if (propName && propType) { + properties[propName] = tsTypeToJsonSchema(propType.trim()); + if (!optional) { + required.push(propName); + } + } + } +} diff --git a/apps/mesh/src/web/components/details/workflow/components/step-detail-panel.tsx b/apps/mesh/src/web/components/details/workflow/components/step-detail-panel.tsx new file mode 100644 index 000000000..5fc0a4439 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/step-detail-panel.tsx @@ -0,0 +1,514 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@deco/ui/components/accordion.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { Repeat03, Plus, CornerDownRight } from "@untitledui/icons"; +import { + Type, + Hash, + Braces, + Box, + CheckSquare, + X, + FileText, + Minus, +} from "lucide-react"; +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { useConnection } from "@/web/hooks/collections/use-connection"; +import { useCurrentStep, useWorkflowActions } from "../stores/workflow"; +import { ToolInput } from "./tool-selection/components/tool-input"; +import type { JsonSchema } from "@/web/utils/constants"; +import { MonacoCodeEditor } from "./monaco-editor"; +import type { Step } from "@decocms/bindings/workflow"; + +interface StepDetailPanelProps { + className?: string; +} + +/** + * Hook to sync step outputSchema from tool outputSchema. + * If the step has a tool but no outputSchema, set it from the tool. + */ +function useSyncOutputSchema(step: Step | undefined) { + const { updateStep } = useWorkflowActions(); + + const isToolStep = step && "toolName" in step.action; + const connectionId = + isToolStep && "connectionId" in step.action + ? step.action.connectionId + : null; + const toolName = + isToolStep && "toolName" in step.action ? step.action.toolName : null; + + const connection = useConnection(connectionId ?? ""); + const tool = connection?.tools?.find((t) => t.name === toolName); + + // Check if step has a tool but outputSchema is empty or missing + const hasToolWithNoOutputSchema = + step && + toolName && + tool?.outputSchema && + (!step.outputSchema || Object.keys(step.outputSchema).length === 0); + + // Sync on first render if needed (runs once when condition is met) + if (hasToolWithNoOutputSchema) { + // Use queueMicrotask to avoid updating state during render + queueMicrotask(() => { + updateStep(step.name, { + outputSchema: tool.outputSchema, + }); + }); + } +} + +export function StepDetailPanel({ className }: StepDetailPanelProps) { + const currentStep = useCurrentStep(); + + // Sync outputSchema from tool if step has tool but no outputSchema + useSyncOutputSchema(currentStep); + + if (!currentStep) { + return ( +
+
+ Select a step to configure +
+
+ ); + } + + const isToolStep = "toolName" in currentStep.action; + const hasToolSelected = + isToolStep && + "toolName" in currentStep.action && + currentStep.action.toolName; + + if (!hasToolSelected) { + return ( +
+
+ Select a tool to configure this step +
+
+ ); + } + + return ( +
+ + + + +
+ ); +} + +// ============================================================================ +// Step Header +// ============================================================================ + +function StepHeader({ step }: { step: Step }) { + const { updateStep, startReplacingTool } = useWorkflowActions(); + const isToolStep = "toolName" in step.action; + const connectionId = + isToolStep && "connectionId" in step.action + ? step.action.connectionId + : null; + const toolName = + isToolStep && "toolName" in step.action ? step.action.toolName : null; + + const connection = useConnection(connectionId ?? ""); + + const handleReplace = () => { + // Store current tool info for back button + if (connectionId && toolName) { + startReplacingTool(connectionId, toolName); + } + // Clear tool selection to show MCP server selector + updateStep(step.name, { + action: { + ...step.action, + connectionId: "", + toolName: "", + }, + }); + }; + + return ( +
+
+ + + {toolName} + + +
+ {step.description && ( +

{step.description}

+ )} +
+ ); +} + +// ============================================================================ +// Input Section +// ============================================================================ + +function InputSection({ step }: { step: Step }) { + const { updateStep } = useWorkflowActions(); + const isToolStep = "toolName" in step.action; + const connectionId = + isToolStep && "connectionId" in step.action + ? step.action.connectionId + : null; + const toolName = + isToolStep && "toolName" in step.action ? step.action.toolName : null; + + const connection = useConnection(connectionId ?? ""); + const tool = connection?.tools?.find((t) => t.name === toolName); + + if (!tool || !tool.inputSchema) { + return null; + } + + const handleInputChange = (formData: Record) => { + updateStep(step.name, { + input: formData, + }); + }; + + return ( + + + + + Input + + + + } + setInputParams={handleInputChange} + mentions={[]} + /> + + + + ); +} + +// ============================================================================ +// Output Section +// ============================================================================ + +function OutputSection({ step }: { step: Step }) { + const outputSchema = step.outputSchema; + + // Always show the Output section (even if empty) + const properties = + outputSchema && typeof outputSchema === "object" + ? ((outputSchema as Record).properties as + | Record + | undefined) + : undefined; + + const propertyEntries = properties ? Object.entries(properties) : []; + + return ( + + + + + Output + + + + {propertyEntries.length === 0 ? ( +
+ No output schema defined +
+ ) : ( +
+ {propertyEntries.map(([key, propSchema]) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function getTypeIcon(type: string) { + switch (type) { + case "string": + return { Icon: Type, color: "text-blue-500" }; + case "number": + case "integer": + return { Icon: Hash, color: "text-blue-500" }; + case "array": + return { Icon: Braces, color: "text-purple-500" }; + case "object": + return { Icon: Box, color: "text-orange-500" }; + case "boolean": + return { Icon: CheckSquare, color: "text-pink-500" }; + case "null": + return { Icon: X, color: "text-gray-500" }; + default: + return { Icon: FileText, color: "text-muted-foreground" }; + } +} + +function OutputProperty({ + name, + schema, +}: { + name: string; + schema: JsonSchema; +}) { + const currentStep = useCurrentStep(); + const type = schema.type ?? "unknown"; + const { Icon, color } = getTypeIcon(type); + + return ( +
+
+ + {name} +
+
+ + {currentStep?.name}. +
+ {name} +
+
+
+ ); +} + +// ============================================================================ +// Transform Code Section +// ============================================================================ + +function TransformCodeSection({ step }: { step: Step }) { + const { updateStep } = useWorkflowActions(); + + const isToolStep = "toolName" in step.action; + const connectionId = + isToolStep && "connectionId" in step.action + ? step.action.connectionId + : null; + const toolName = + isToolStep && "toolName" in step.action ? step.action.toolName : null; + + const connection = useConnection(connectionId ?? ""); + const tool = connection?.tools?.find((t) => t.name === toolName); + + const transformCode = + isToolStep && "transformCode" in step.action + ? (step.action.transformCode ?? null) + : null; + + const hasTransformCode = Boolean(transformCode); + + // Generate Input interface from tool's output schema + const generateInputInterface = (): string => { + if (!tool?.outputSchema) { + return "interface Input {\n // Tool output schema not available\n}"; + } + + const schema = tool.outputSchema as JsonSchema; + const properties = schema.properties as + | Record + | undefined; + + if (!properties) { + return "interface Input {\n [key: string]: unknown;\n}"; + } + + const fields = Object.entries(properties) + .map(([key, prop]) => { + const type = jsonSchemaTypeToTS(prop); + const optional = !(schema.required as string[] | undefined)?.includes( + key, + ); + return ` ${key}${optional ? "?" : ""}: ${type};`; + }) + .join("\n"); + + return `interface Input {\n${fields}\n}`; + }; + + const handleAddTransformCode = () => { + const inputInterface = generateInputInterface(); + const defaultCode = `${inputInterface} + +interface Output { + // Define your output type here + result: unknown; +} + +export default async function(input: Input): Promise { + // Transform the tool output + return { + result: input, + }; +}`; + + updateStep(step.name, { + action: { + ...step.action, + transformCode: defaultCode, + }, + }); + }; + + const handleRemoveTransformCode = () => { + updateStep(step.name, { + action: { + ...step.action, + transformCode: undefined, + }, + }); + }; + + const handleCodeSave = ( + code: string, + outputSchema: Record | null, + ) => { + // Update both the transform code and the output schema + updateStep(step.name, { + action: { + ...step.action, + transformCode: code, + }, + // If we extracted an output schema from the TypeScript, use it + ...(outputSchema ? { outputSchema } : {}), + }); + }; + + // No transform code → show collapsed with Plus + if (!hasTransformCode) { + return ( +
+
+

+ Transform Code +

+ +
+
+ ); + } + + // Has transform code → show editor with Minus to remove + return ( +
+
+
+

+ Transform Code +

+ +
+
+
+ +
+
+ ); +} + +// Helper function to convert JSON Schema types to TypeScript types +function jsonSchemaTypeToTS(schema: JsonSchema): string { + if (Array.isArray(schema.type)) { + return schema.type + .map((t) => jsonSchemaTypeToTS({ ...schema, type: t })) + .join(" | "); + } + + const type = schema.type as string | undefined; + + switch (type) { + case "string": + return "string"; + case "number": + case "integer": + return "number"; + case "boolean": + return "boolean"; + case "array": + if (schema.items) { + const itemType = jsonSchemaTypeToTS(schema.items as JsonSchema); + return `${itemType}[]`; + } + return "unknown[]"; + case "object": + if (schema.properties) { + const props = Object.entries( + schema.properties as Record, + ) + .map(([key, prop]) => { + const propType = jsonSchemaTypeToTS(prop); + const optional = !( + schema.required as string[] | undefined + )?.includes(key); + return `${key}${optional ? "?" : ""}: ${propType}`; + }) + .join("; "); + return `{ ${props} }`; + } + return "Record"; + case "null": + return "null"; + default: + return "unknown"; + } +} diff --git a/apps/mesh/src/web/components/details/workflow/components/tool-selection/components/readonly-tool-input.tsx b/apps/mesh/src/web/components/details/workflow/components/tool-selection/components/readonly-tool-input.tsx new file mode 100644 index 000000000..6cdba57ec --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/tool-selection/components/readonly-tool-input.tsx @@ -0,0 +1,65 @@ +import type { JsonSchema } from "@/web/utils/constants"; +import type { MentionItem } from "@/web/components/tiptap-mentions-input"; +import { MentionInput } from "@/web/components/tiptap-mentions-input"; + +/** + * Renders a clean readonly view of form data with mention tooltips + */ +export function ReadonlyToolInput({ + inputSchema, + inputParams, + mentions, +}: { + inputSchema: JsonSchema; + inputParams?: Record; + mentions: MentionItem[]; +}) { + if (!inputSchema?.properties || !inputParams) { + return null; + } + + const renderValue = (key: string, value: unknown, schema: JsonSchema) => { + // Convert value to string for display + const valueStr = + typeof value === "object" && value !== null + ? JSON.stringify(value, null, 2) + : String(value ?? ""); + + const schemaType = Array.isArray(schema.type) + ? schema.type.join(" | ") + : (schema.type ?? "string"); + + return ( +
+
+ {key} + + {schemaType} + +
+ {schema.description && ( +
+ {schema.description} +
+ )} +
+ +
+
+ ); + }; + + return ( +
+ {Object.entries(inputSchema.properties).map(([key, propSchema]) => { + const value = inputParams[key]; + return renderValue(key, value, propSchema as JsonSchema); + })} +
+ ); +} diff --git a/apps/mesh/src/web/components/details/workflow/components/tool-selection/components/tool-input.tsx b/apps/mesh/src/web/components/details/workflow/components/tool-selection/components/tool-input.tsx new file mode 100644 index 000000000..d778b82b0 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/tool-selection/components/tool-input.tsx @@ -0,0 +1,85 @@ +import Form from "@rjsf/core"; +import type { RJSFSchema } from "@rjsf/utils"; +import validator from "@rjsf/validator-ajv8"; +import type { JsonSchema } from "@/web/utils/constants"; +import type { MentionItem } from "@/web/components/tiptap-mentions-input"; +import { MentionsContext } from "../rjsf/rjsf-context"; +import { customWidgets } from "../rjsf/rjsf-widgets"; +import { customTemplates } from "../rjsf/rjsf-templates"; +import { ReadonlyToolInput } from "./readonly-tool-input"; + +export function ToolInput({ + inputSchema, + inputParams, + setInputParams, + handleInputChange, + mentions, + readOnly, +}: { + inputSchema: JsonSchema; + inputParams?: Record; + setInputParams?: (params: Record) => void; + handleInputChange?: (key: string, value: unknown) => void; + mentions?: MentionItem[]; + readOnly?: boolean | undefined; +}) { + const mentionItems = mentions ?? []; + + if (!inputSchema) { + return ( +
+ No arguments defined in schema. +
+ ); + } + + // If readonly, use the clean readonly view + if (readOnly) { + return ( + + ); + } + + // Convert JsonSchema to RJSFSchema + const rjsfSchema: RJSFSchema = inputSchema as RJSFSchema; + + const handleChange = (data: { formData?: Record }) => { + const formData = data.formData ?? {}; + setInputParams?.(formData); + + // Call handleInputChange for each changed key + if (handleInputChange) { + for (const [key, value] of Object.entries(formData)) { + handleInputChange(key, value); + } + } + }; + + return ( + +
+ {/* Empty children to hide submit button */} + <> +
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-context.tsx b/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-context.tsx new file mode 100644 index 000000000..132049a40 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-context.tsx @@ -0,0 +1,8 @@ +import { createContext, useContext } from "react"; +import type { MentionItem } from "@/web/components/tiptap-mentions-input"; + +export const MentionsContext = createContext([]); + +export function useMentions() { + return useContext(MentionsContext); +} diff --git a/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-templates.tsx b/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-templates.tsx new file mode 100644 index 000000000..d58ca7126 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-templates.tsx @@ -0,0 +1,160 @@ +import type { + FieldTemplateProps, + ObjectFieldTemplateProps, + ArrayFieldTemplateProps, + TemplatesType, +} from "@rjsf/utils"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Type, + Hash, + Braces, + Box, + CheckSquare, + X, + FileText, +} from "lucide-react"; +import { cn } from "@deco/ui/lib/utils.ts"; + +function getTypeIcon(type: string | string[] | undefined) { + const typeStr = Array.isArray(type) ? type[0] : (type ?? "unknown"); + switch (typeStr) { + case "string": + return { Icon: Type, color: "text-blue-500" }; + case "number": + case "integer": + return { Icon: Hash, color: "text-blue-500" }; + case "array": + return { Icon: Braces, color: "text-purple-500" }; + case "object": + return { Icon: Box, color: "text-orange-500" }; + case "boolean": + return { Icon: CheckSquare, color: "text-pink-500" }; + case "null": + return { Icon: X, color: "text-gray-500" }; + default: + return { Icon: FileText, color: "text-muted-foreground" }; + } +} + +/** + * Custom FieldTemplate - wraps each field with label, description, and type indicator + */ +function CustomFieldTemplate(props: FieldTemplateProps) { + const { id, label, required, description, children, schema, hidden } = props; + + if (hidden) return
{children}
; + + // Don't show label/description for root object + if (id === "root") { + return
{children}
; + } + + const schemaType = Array.isArray(schema.type) + ? schema.type[0] + : (schema.type ?? "string"); + const { Icon, color } = getTypeIcon(schemaType); + + return ( +
+
+ + {required && *} + +
+ {description && ( +
{description}
+ )} + {children} +
+ ); +} + +/** + * Custom ObjectFieldTemplate - renders nested objects with left border indent + */ +function CustomObjectFieldTemplate(props: ObjectFieldTemplateProps) { + const { properties, title } = props; + // Use title to determine if root - root usually has no title or "Root" + const isRoot = !title || title === "Root"; + + // Root object - no wrapper + if (isRoot) { + return ( +
+ {properties.map((prop) => ( +
{prop.content}
+ ))} +
+ ); + } + + // Nested object - show with left border + return ( +
+ {properties.map((prop) => ( +
{prop.content}
+ ))} +
+ ); +} + +/** + * Custom ArrayFieldTemplate - renders arrays with add/remove controls + */ +function CustomArrayFieldTemplate(props: ArrayFieldTemplateProps) { + const { items, canAdd, onAddClick, title } = props; + + return ( +
+
+ {items.map((item) => ( +
+
{item}
+
+ ))} +
+ {canAdd && ( + + )} +
+ ); +} + +/** + * Custom UnsupportedFieldTemplate - hides unsupported field errors + */ +function CustomUnsupportedFieldTemplate() { + // Return null to hide unsupported field errors + return null; +} + +/** + * Custom ErrorListTemplate - hides the error list at the top of the form + */ +function CustomErrorListTemplate() { + // Return null to hide error list + return null; +} + +// Custom templates registry +export const customTemplates: Partial = { + FieldTemplate: CustomFieldTemplate, + ObjectFieldTemplate: CustomObjectFieldTemplate, + ArrayFieldTemplate: CustomArrayFieldTemplate, + UnsupportedFieldTemplate: CustomUnsupportedFieldTemplate, + ErrorListTemplate: CustomErrorListTemplate, +}; diff --git a/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-widgets.tsx b/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-widgets.tsx new file mode 100644 index 000000000..3ead021dc --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/tool-selection/rjsf/rjsf-widgets.tsx @@ -0,0 +1,124 @@ +import { cn } from "@deco/ui/lib/utils.ts"; +import type { WidgetProps, RegistryWidgetsType } from "@rjsf/utils"; +import { MentionInput } from "@/web/components/tiptap-mentions-input"; +import { useMentions } from "./rjsf-context"; + +/** + * Text widget using MentionInput + */ +function MentionTextWidget(props: WidgetProps) { + const { value, onChange, placeholder, readonly } = props; + const mentions = useMentions(); + + return ( + onChange(v)} + placeholder={placeholder || `Enter value...`} + readOnly={readonly} + /> + ); +} + +/** + * Textarea widget using MentionInput with multiline styling + */ +function MentionTextareaWidget(props: WidgetProps) { + const { value, onChange, placeholder, readonly } = props; + const mentions = useMentions(); + + return ( + onChange(v)} + placeholder={placeholder || `Enter value...`} + readOnly={readonly} + className="min-h-[80px]" + /> + ); +} + +/** + * Number widget + */ +function NumberWidget(props: WidgetProps) { + const { value, onChange, readonly, id } = props; + + return ( + + onChange(e.target.value === "" ? undefined : Number(e.target.value)) + } + disabled={readonly} + className={cn( + "w-full rounded-md border border-input bg-background px-3 py-2 text-sm", + "ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-50", + )} + /> + ); +} + +/** + * Checkbox widget + */ +function CheckboxWidget(props: WidgetProps) { + const { value, onChange, readonly, id, label } = props; + + return ( + + ); +} + +/** + * Select widget + */ +function SelectWidget(props: WidgetProps) { + const { value, onChange, readonly, id, options } = props; + const enumOptions = options.enumOptions ?? []; + + return ( + + ); +} + +// Custom widgets registry +export const customWidgets: RegistryWidgetsType = { + TextWidget: MentionTextWidget, + TextareaWidget: MentionTextareaWidget, + NumberWidget: NumberWidget, + CheckboxWidget: CheckboxWidget, + SelectWidget: SelectWidget, +}; diff --git a/apps/mesh/src/web/components/details/workflow/components/tool-selector.tsx b/apps/mesh/src/web/components/details/workflow/components/tool-selector.tsx new file mode 100644 index 000000000..77f48ea2c --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/tool-selector.tsx @@ -0,0 +1,2 @@ +// Barrel file - re-export hooks +export { useResolvedRefs } from "../hooks"; diff --git a/apps/mesh/src/web/components/details/workflow/components/tool-sidebar.tsx b/apps/mesh/src/web/components/details/workflow/components/tool-sidebar.tsx new file mode 100644 index 000000000..67bb8289d --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/tool-sidebar.tsx @@ -0,0 +1,283 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { ArrowLeft } from "@untitledui/icons"; +import { + useConnections, + useConnection, +} from "@/web/hooks/collections/use-connection"; +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { usePrioritizedList } from "../hooks"; +import { + useCurrentStep, + useWorkflowActions, + useReplacingToolInfo, +} from "../stores/workflow"; + +interface ToolSidebarProps { + className?: string; +} + +export function ToolSidebar({ className }: ToolSidebarProps) { + const currentStep = useCurrentStep(); + const isToolStep = currentStep && "toolName" in currentStep.action; + const connectionId = + isToolStep && "connectionId" in currentStep.action + ? currentStep.action.connectionId + : null; + + // If step has a connectionId, show tool selector; otherwise show connection selector + if (connectionId) { + return ; + } + + return ; +} + +// ============================================================================ +// Connection Selector +// ============================================================================ + +function ConnectionSelector({ className }: { className?: string }) { + const connections = useConnections(); + const currentStep = useCurrentStep(); + const { updateStep, cancelReplacingTool } = useWorkflowActions(); + const replacingToolInfo = useReplacingToolInfo(); + + const isToolStep = currentStep && "toolName" in currentStep.action; + const selectedConnectionId = + isToolStep && "connectionId" in currentStep.action + ? currentStep.action.connectionId + : null; + + const selectedConnection = connections.find( + (c) => c.id === selectedConnectionId, + ); + + const prioritizedConnections = usePrioritizedList( + connections, + selectedConnection ?? null, + (c) => c.title, + (a, b) => a.title.localeCompare(b.title), + ); + + const handleSelectConnection = (connectionId: string) => { + if (!currentStep) return; + updateStep(currentStep.name, { + action: { + ...currentStep.action, + connectionId, + toolName: "", + }, + }); + // Clear replacing info when selecting new connection + cancelReplacingTool(); + }; + + const handleBack = () => { + if (!currentStep || !replacingToolInfo) return; + // Restore previous tool selection + updateStep(currentStep.name, { + action: { + ...currentStep.action, + connectionId: replacingToolInfo.connectionId, + toolName: replacingToolInfo.toolName, + }, + }); + // Clear replacing info + cancelReplacingTool(); + }; + + return ( +
+ {/* Header */} +
+ {replacingToolInfo && ( +
+ +
+ )} +
+ + Select MCP Server + +
+
+ + {/* Connection List */} +
+ {prioritizedConnections.map((connection) => ( + handleSelectConnection(connection.id)} + /> + ))} +
+
+ ); +} + +// ============================================================================ +// Tool Selector +// ============================================================================ + +function ToolSelector({ + connectionId, + className, +}: { + connectionId: string; + className?: string; +}) { + const connection = useConnection(connectionId); + const currentStep = useCurrentStep(); + const { updateStep, cancelReplacingTool } = useWorkflowActions(); + + const tools = connection?.tools ?? []; + const isToolStep = currentStep && "toolName" in currentStep.action; + const selectedToolName = + isToolStep && "toolName" in currentStep.action + ? currentStep.action.toolName + : null; + + const selectedTool = tools.find((t) => t.name === selectedToolName); + + const prioritizedTools = usePrioritizedList( + tools, + selectedTool ?? null, + (t) => t.name, + (a, b) => a.name.localeCompare(b.name), + ); + + const handleSelectTool = (toolName: string) => { + if (!currentStep) return; + + // Find the selected tool to get its outputSchema + const selectedToolData = tools.find((t) => t.name === toolName); + + updateStep(currentStep.name, { + action: { + ...currentStep.action, + toolName, + }, + // Set the step's outputSchema to the tool's outputSchema + outputSchema: selectedToolData?.outputSchema ?? {}, + }); + // Clear replacing info when selecting a tool + cancelReplacingTool(); + }; + + const handleBack = () => { + if (!currentStep) return; + updateStep(currentStep.name, { + action: { + ...currentStep.action, + connectionId: "", + toolName: "", + }, + }); + // Don't clear replacingToolInfo here - user is going back to connection selector + }; + + return ( +
+ {/* Header with back button */} +
+ {/* Back Button */} +
+ +
+ + {/* Title and Connection */} +
+ + Select tool + + + {/* Connection Badge */} +
+ + + {connection?.title} + +
+
+
+ + {/* Tool List */} +
+ {prioritizedTools.map((tool) => ( + handleSelectTool(tool.name)} + /> + ))} +
+
+ ); +} + +// ============================================================================ +// Shared Components +// ============================================================================ + +interface SidebarRowProps { + icon: string | null; + title: string; + isSelected: boolean; + onClick: () => void; +} + +function SidebarRow({ icon, title, isSelected, onClick }: SidebarRowProps) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + > + + + {title} + +
+ ); +} diff --git a/apps/mesh/src/web/components/details/workflow/components/workflow-editor-header.tsx b/apps/mesh/src/web/components/details/workflow/components/workflow-editor-header.tsx new file mode 100644 index 000000000..0ee1d11b9 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/workflow-editor-header.tsx @@ -0,0 +1,194 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { ViewModeToggle } from "@deco/ui/components/view-mode-toggle.tsx"; +import { + ArrowLeft, + ClockFastForward, + Code02, + FlipBackward, + GitBranch01, + Play, + Save02, +} from "@untitledui/icons"; +import { Spinner } from "@deco/ui/components/spinner.tsx"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { useViewModeStore, type WorkflowViewMode } from "../stores/view-mode"; +import { + useIsDirty, + useTrackingExecutionId, + useWorkflowActions, + useWorkflowSteps, +} from "../stores/workflow"; +import { usePollingWorkflowExecution, useWorkflowStart } from "../hooks"; +import { cn } from "@deco/ui/lib/utils.ts"; + +interface WorkflowEditorHeaderProps { + title: string; + description?: string; + onBack: () => void; + onSave: () => void; +} + +export function WorkflowEditorHeader({ + title, + description, + onBack, + onSave, +}: WorkflowEditorHeaderProps) { + const { viewMode, setViewMode, showExecutionsList, toggleExecutionsList } = + useViewModeStore(); + const { resetToOriginalWorkflow } = useWorkflowActions(); + const isDirty = useIsDirty(); + + return ( +
+ {/* Back Button */} +
+ +
+ + {/* Title and Description */} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ + {/* Right Actions */} +
+ {/* View Mode Toggle */} + + value={viewMode} + onValueChange={setViewMode} + size="sm" + options={[ + { value: "visual", icon: }, + { value: "code", icon: }, + ]} + /> + + {/* Undo Button */} + + + {/* Save Button */} + + + {/* Runs List Toggle */} + + + {/* Run Workflow Button */} + +
+
+ ); +} + +function useIsExecutionCompleted() { + const trackingExecutionId = useTrackingExecutionId(); + const { item } = usePollingWorkflowExecution(trackingExecutionId); + return item?.completed_at_epoch_ms !== null; +} + +function RunWorkflowButton() { + const isDirty = useIsDirty(); + const isExecutionCompleted = useIsExecutionCompleted(); + const trackingExecutionId = useTrackingExecutionId(); + const { handleRunWorkflow } = useWorkflowStart(); + const steps = useWorkflowSteps(); + const trackingExecutionIsRunning = + trackingExecutionId && !isExecutionCompleted; + + const hasEmptySteps = steps.some( + (step) => + "toolName" in step.action && + (!step.action.toolName || step.action.toolName === ""), + ); + + const isDisabled = trackingExecutionIsRunning || isDirty || hasEmptySteps; + + const getTooltipMessage = () => { + if (trackingExecutionIsRunning) return "Workflow is currently running"; + if (isDirty) return "Save your changes before running"; + if (hasEmptySteps) return "All steps must have a tool selected"; + return null; + }; + + const tooltipMessage = getTooltipMessage(); + + const button = ( + + ); + + if (!tooltipMessage) return button; + + return ( + + + + {button} + + {tooltipMessage} + + + ); +} diff --git a/apps/mesh/src/web/components/details/workflow/components/workflow-step-card.tsx b/apps/mesh/src/web/components/details/workflow/components/workflow-step-card.tsx new file mode 100644 index 000000000..ee0d83322 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/workflow-step-card.tsx @@ -0,0 +1,215 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { DotsHorizontal, Tool01 } from "@untitledui/icons"; +import { Code } from "lucide-react"; +import type { Step } from "@decocms/bindings/workflow"; +import { useConnection } from "@/web/hooks/collections/use-connection"; +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@deco/ui/components/dropdown-menu.tsx"; +import { Trash2, Copy } from "lucide-react"; + +interface WorkflowStepCardProps { + step: Step; + index: number; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; + onDuplicate: () => void; +} + +export function WorkflowStepCard({ + step, + index, + isSelected, + onSelect, + onDelete, + onDuplicate, +}: WorkflowStepCardProps) { + const isToolStep = "toolName" in step.action; + const connectionId = + isToolStep && "connectionId" in step.action + ? step.action.connectionId + : null; + const toolName = + isToolStep && "toolName" in step.action ? step.action.toolName : null; + const hasToolSelected = Boolean(toolName); + const outputSchemaProperties = getOutputSchemaProperties(step); + // Connector starts after icon (32px aligns with header 32px) + // Just measure: gap-3 (12) + tags (~40 if exist) + pb-3 (12) + spacing (12) + const connectorHeight = outputSchemaProperties.length > 0 ? 60 : 12; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + > + {/* Line Number */} +
+ + {index + 1}. + +
+ + {/* Icon + Connector */} +
+ {index > 0 ? ( + + ) : ( +
+ )} + + +
+ + {/* Content */} +
+ {/* Header Row */} +
+ + {getStepDisplayName(step)} + + + {/* Actions Menu */} + + + + + + { + e.stopPropagation(); + onDuplicate(); + }} + > + + Duplicate + + { + e.stopPropagation(); + onDelete(); + }} + > + + Delete + + + +
+ + {/* Output Schema Tags */} + {outputSchemaProperties.length > 0 && ( +
+ {outputSchemaProperties.map((prop) => ( + + {prop} + + ))} +
+ )} +
+
+ ); +} + +function StepIcon({ + connectionId, + isToolStep, + hasToolSelected, + stepName, +}: { + connectionId: string | null; + isToolStep: boolean; + hasToolSelected: boolean; + stepName: string; +}) { + const connection = useConnection(connectionId ?? ""); + + if (isToolStep && hasToolSelected && connection?.icon) { + return ( +
+ +
+ ); + } + + return ( +
+ {isToolStep ? ( + + ) : ( + + )} +
+ ); +} + +function VerticalConnector({ height }: { height: number }) { + return
; +} + +function getStepDisplayName(step: Step): string { + if ("toolName" in step.action && step.action.toolName) { + return step.action.toolName; + } + if ("code" in step.action) { + return step.name || "Code Step"; + } + return "Select tool..."; +} + +function getOutputSchemaProperties(step: Step): string[] { + const schema = step.outputSchema; + if (!schema || typeof schema !== "object") return []; + + const properties = (schema as Record).properties; + if (!properties || typeof properties !== "object") return []; + + // Get top-level property names + return Object.keys(properties as Record).slice(0, 5); +} diff --git a/apps/mesh/src/web/components/details/workflow/components/workflow-steps-canvas.tsx b/apps/mesh/src/web/components/details/workflow/components/workflow-steps-canvas.tsx new file mode 100644 index 000000000..266d9057b --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/components/workflow-steps-canvas.tsx @@ -0,0 +1,71 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { Plus } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + useCurrentStepName, + useWorkflowActions, + useWorkflowSteps, +} from "../stores/workflow"; +import { WorkflowStepCard } from "./workflow-step-card"; + +interface WorkflowStepsCanvasProps { + className?: string; +} + +export function WorkflowStepsCanvas({ className }: WorkflowStepsCanvasProps) { + const steps = useWorkflowSteps(); + const currentStepName = useCurrentStepName(); + const { setCurrentStepName, deleteStep, duplicateStep, addToolStep } = + useWorkflowActions(); + + return ( +
+ {/* Steps Container */} +
+ {/* Steps List */} +
+ {steps.map((step, index) => ( + setCurrentStepName(step.name)} + onDelete={() => deleteStep(step.name)} + onDuplicate={() => duplicateStep(step.name)} + /> + ))} +
+ + {/* Add Step Button */} +
+ {/* Empty space for line number alignment */} +
+ + {/* Connector and Add Button */} +
+
+ +
+
+
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/derived/use-current-step-execution-result.ts b/apps/mesh/src/web/components/details/workflow/hooks/derived/use-current-step-execution-result.ts new file mode 100644 index 000000000..4d32fe972 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/derived/use-current-step-execution-result.ts @@ -0,0 +1,36 @@ +import { usePollingWorkflowExecution } from "../queries/use-workflow-collection-item"; +import { + useTrackingExecutionId, + useCurrentStepName, +} from "../../stores/workflow"; + +/** + * Returns the execution result for the currently selected step. + * Only returns data when tracking an execution and the step has been executed. + */ +export function useCurrentStepExecutionResult() { + const trackingExecutionId = useTrackingExecutionId(); + const currentStepName = useCurrentStepName(); + const { + step_results, + item: executionItem, + isLoading, + } = usePollingWorkflowExecution(trackingExecutionId); + + if (!trackingExecutionId || !currentStepName || !step_results) { + return { output: undefined, isLoading, isTracking: !!trackingExecutionId }; + } + + // Find the result for the current step + const stepResult = step_results.find( + (result: { step_id?: unknown; output?: unknown }) => + result.step_id === currentStepName, + ); + + return { + output: stepResult?.output, + status: executionItem?.status, + isLoading, + isTracking: true, + }; +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/derived/use-resolved-refs.ts b/apps/mesh/src/web/components/details/workflow/hooks/derived/use-resolved-refs.ts new file mode 100644 index 000000000..df16a91d0 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/derived/use-resolved-refs.ts @@ -0,0 +1,26 @@ +import { usePollingWorkflowExecution } from "../queries/use-workflow-collection-item"; +import { useTrackingExecutionId } from "../../stores/workflow"; + +export function useResolvedRefs() { + const trackingExecutionId = useTrackingExecutionId(); + const { step_results, item: executionItem } = + usePollingWorkflowExecution(trackingExecutionId); + const resolvedRefs: Record | undefined = + trackingExecutionId && step_results + ? (() => { + const refs: Record = {}; + // Add workflow input as "input" + if (executionItem?.input) { + refs["input"] = executionItem.input; + } + // Add each step's output by step_id + for (const result of step_results) { + if (result.step_id && result.output !== undefined) { + refs[result.step_id as string] = result.output; + } + } + return refs; + })() + : undefined; + return resolvedRefs; +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/derived/use-step-execution-status.ts b/apps/mesh/src/web/components/details/workflow/hooks/derived/use-step-execution-status.ts new file mode 100644 index 000000000..04f5c81be --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/derived/use-step-execution-status.ts @@ -0,0 +1,132 @@ +import { usePollingWorkflowExecution } from "../queries/use-workflow-collection-item"; +import { + useTrackingExecutionId, + useWorkflowSteps, +} from "../../stores/workflow"; + +export interface StepExecutionStatus { + status: "pending" | "running" | "success" | "error"; + output?: unknown; + error?: string; + /** Index of the step in the steps array (for determining order) */ + stepIndex: number; +} + +/** + * Returns execution status for all steps when tracking an execution. + * Determines step status based on: + * - If step has output in step_results -> success + * - If step has error in step_results -> error + * - If execution is running and step is next in line -> running + * - Otherwise -> pending + */ +export function useStepExecutionStatuses(): + | Record + | undefined { + const trackingExecutionId = useTrackingExecutionId(); + const steps = useWorkflowSteps(); + const { step_results, item: executionItem } = + usePollingWorkflowExecution(trackingExecutionId); + + if (!trackingExecutionId || !executionItem) { + return undefined; + } + + const statuses: Record = {}; + + // Build a map of step results by step_id + const resultsByStepId = new Map< + string, + { output?: unknown; error?: unknown } + >(); + if (step_results) { + for (const result of step_results) { + const stepId = result.step_id as string | undefined; + if (stepId) { + resultsByStepId.set(stepId, { + output: result.output, + error: result.error, + }); + } + } + } + + // Find the last completed step index to determine which step is currently running + let lastCompletedIndex = -1; + + steps.forEach((step, index) => { + const result = resultsByStepId.get(step.name); + if (result?.output !== undefined || result?.error !== undefined) { + lastCompletedIndex = index; + } + }); + + // Determine status for each step + steps.forEach((step, index) => { + const result = resultsByStepId.get(step.name); + + let status: StepExecutionStatus["status"] = "pending"; + + if (result?.error !== undefined) { + status = "error"; + } else if (result?.output !== undefined) { + status = "success"; + } else if ( + executionItem.status === "running" && + index === lastCompletedIndex + 1 + ) { + // This step is currently running (it's the first step without a result) + status = "running"; + } + + statuses[step.name] = { + status, + output: result?.output, + error: typeof result?.error === "string" ? result.error : undefined, + stepIndex: index, + }; + }); + + return statuses; +} + +/** + * Returns the execution metadata for the workflow execution being tracked. + */ +export function useExecutionMetadata() { + const trackingExecutionId = useTrackingExecutionId(); + const { item: executionItem, isLoading } = + usePollingWorkflowExecution(trackingExecutionId); + + if (!trackingExecutionId || !executionItem) { + return { + isTracking: !!trackingExecutionId, + isLoading, + status: undefined, + startedAt: undefined, + completedAt: undefined, + durationMs: undefined, + }; + } + + const startedAt = executionItem.start_at_epoch_ms + ? new Date(executionItem.start_at_epoch_ms) + : undefined; + const completedAt = executionItem.completed_at_epoch_ms + ? new Date(executionItem.completed_at_epoch_ms) + : undefined; + const durationMs = + startedAt && completedAt + ? completedAt.getTime() - startedAt.getTime() + : undefined; + + return { + isTracking: true, + isLoading, + status: executionItem.status, + startedAt, + completedAt, + durationMs, + error: executionItem.error, + }; +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/index.ts b/apps/mesh/src/web/components/details/workflow/hooks/index.ts new file mode 100644 index 000000000..edd678979 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/index.ts @@ -0,0 +1,13 @@ +// Queries +export * from "./queries/use-workflow-collection-item"; +export * from "./queries/use-workflow-executions"; + +// Mutations +export * from "./mutations/use-workflow-start"; + +// Derived +export * from "./derived/use-resolved-refs"; + +// Infrastructure +export * from "./use-workflow-binding-connection"; +export * from "./use-prioritized-list"; diff --git a/apps/mesh/src/web/components/details/workflow/hooks/mutations/use-workflow-start.ts b/apps/mesh/src/web/components/details/workflow/hooks/mutations/use-workflow-start.ts new file mode 100644 index 000000000..6145b136a --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/mutations/use-workflow-start.ts @@ -0,0 +1,39 @@ +import { useToolCallMutation } from "@/web/hooks/use-tool-call"; +import { createToolCaller } from "@/tools/client"; +import { + useWorkflow, + useWorkflowActions, +} from "@/web/components/details/workflow/stores/workflow"; +import { useWorkflowBindingConnection } from "../use-workflow-binding-connection"; + +export function useWorkflowStart() { + const { id: connectionId } = useWorkflowBindingConnection(); + const { setTrackingExecutionId } = useWorkflowActions(); + const toolCaller = createToolCaller(connectionId); + const workflow = useWorkflow(); + const { mutateAsync: startWorkflow, isPending } = useToolCallMutation({ + toolCaller, + toolName: "COLLECTION_WORKFLOW_EXECUTION_CREATE", + }); + const handleRunWorkflow = async () => { + const startAtEpochMs = Date.now(); + const timeoutMs = 30000; + const result = await startWorkflow({ + steps: workflow.steps, + input: { + + }, + gateway_id: 'gw_NVZj-H9VxwOMRt-1M6Ntf', + start_at_epoch_ms: startAtEpochMs, + timeout_ms: timeoutMs, + }); + + const executionId = + (result as { id: string }).id ?? + (result as { structuredContent: { id: string } }).structuredContent.id; + setTrackingExecutionId(executionId); + return executionId; + }; + + return { handleRunWorkflow, isPending }; +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/queries/use-workflow-collection-item.ts b/apps/mesh/src/web/components/details/workflow/hooks/queries/use-workflow-collection-item.ts new file mode 100644 index 000000000..a669f608a --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/queries/use-workflow-collection-item.ts @@ -0,0 +1,75 @@ +import { useParams } from "@tanstack/react-router"; +import { useCollectionItem } from "@/web/hooks/use-collections"; +import { Workflow, WorkflowExecution } from "@decocms/bindings/workflow"; +import { createToolCaller, UNKNOWN_CONNECTION_ID } from "@/tools/client"; +import { useWorkflowBindingConnection } from "../use-workflow-binding-connection"; +import { useToolCallQuery } from "@/web/hooks/use-tool-call"; +import { Query } from "@tanstack/react-query"; + +export function useWorkflowCollectionItem(itemId: string) { + const { connectionId } = useParams({ + strict: false, + }); + const toolCaller = createToolCaller(connectionId ?? UNKNOWN_CONNECTION_ID); + const item = useCollectionItem( + connectionId ?? UNKNOWN_CONNECTION_ID, + "workflow", + itemId, + toolCaller, + ); + return { + item, + update: (updates: Record) => { + toolCaller("COLLECTION_WORKFLOW_UPDATE", { + id: itemId, + data: updates, + }); + }, + }; +} + +export function usePollingWorkflowExecution(executionId?: string) { + const connection = useWorkflowBindingConnection(); + const toolCaller = createToolCaller(connection.id); + const { data, isLoading } = useToolCallQuery({ + toolCaller: toolCaller, + toolName: "COLLECTION_WORKFLOW_EXECUTION_GET", + toolInputParams: { + id: executionId, + }, + scope: connection.id, + enabled: !!executionId, + refetchInterval: executionId + ? ( + query: Query< + { + item: WorkflowExecution | null; + step_results: Record | null; + }, + Error, + { + item: WorkflowExecution | null; + step_results: Record | null; + }, + readonly unknown[] + >, + ) => { + return (query.state?.data?.item?.completed_at_epoch_ms === null && + query.state?.data?.item?.status === "running") || + query.state?.data?.item?.status === "enqueued" + ? 2000 + : false; + } + : false, + }); + + return { + item: data?.item, + step_results: data?.step_results, + isLoading, + } as { + item: WorkflowExecution | null; + step_results: Record[] | null; + isLoading: boolean; + }; +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/queries/use-workflow-executions.ts b/apps/mesh/src/web/components/details/workflow/hooks/queries/use-workflow-executions.ts new file mode 100644 index 000000000..407f09f10 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/queries/use-workflow-executions.ts @@ -0,0 +1,40 @@ +import { useToolCallQuery } from "@/web/hooks/use-tool-call"; +import { createToolCaller } from "@/tools/client"; +import { useWorkflowBindingConnection } from "../use-workflow-binding-connection"; +import { useWorkflow } from "../../stores/workflow"; +import type { WorkflowExecution } from "@decocms/bindings/workflow"; + +interface WorkflowExecutionsListResponse { + items: WorkflowExecution[]; +} + +/** + * Hook to list all executions for the current workflow. + * Returns executions sorted by most recent first. + */ +export function useWorkflowExecutions() { + const connection = useWorkflowBindingConnection(); + const workflow = useWorkflow(); + const toolCaller = createToolCaller(connection.id); + + const { data, isLoading, refetch } = useToolCallQuery({ + toolCaller: toolCaller, + toolName: "COLLECTION_WORKFLOW_EXECUTION_LIST", + toolInputParams: { + where: `workflow = '${workflow.id}'`, + orderBy: "created_at DESC", + limit: 100, + }, + scope: `${connection.id}-executions-${workflow.id}`, + enabled: !!workflow.id, + staleTime: 5000, + }); + + const response = data as WorkflowExecutionsListResponse | undefined; + + return { + executions: response?.items ?? [], + isLoading, + refetch, + }; +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/use-prioritized-list.ts b/apps/mesh/src/web/components/details/workflow/hooks/use-prioritized-list.ts new file mode 100644 index 000000000..2a3951133 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/use-prioritized-list.ts @@ -0,0 +1,26 @@ +/** + * Hook to prioritize a list by placing a selected item first. + * Useful for connection and tool selectors where the selected item + * should appear at the top of the list. + */ +export function usePrioritizedList( + items: T[], + selectedItem: T | null | undefined, + getKey: (item: T) => string, + compareFn?: (a: T, b: T) => number, +): T[] { + if (!selectedItem) { + return [...items].sort(compareFn); + } + + const selectedKey = getKey(selectedItem); + return [...items].sort((a, b) => { + const aKey = getKey(a); + const bKey = getKey(b); + + if (aKey === selectedKey) return -1; + if (bKey === selectedKey) return 1; + + return compareFn ? compareFn(a, b) : 0; + }); +} diff --git a/apps/mesh/src/web/components/details/workflow/hooks/use-workflow-binding-connection.ts b/apps/mesh/src/web/components/details/workflow/hooks/use-workflow-binding-connection.ts new file mode 100644 index 000000000..ee45da71a --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/hooks/use-workflow-binding-connection.ts @@ -0,0 +1,15 @@ +import { useConnections } from "@/web/hooks/collections/use-connection"; +import { useBindingConnections } from "@/web/hooks/use-binding"; + +export function useWorkflowBindingConnection() { + const connections = useConnections(); + const connection = useBindingConnections({ + connections, + binding: "WORKFLOW", + }); + if (!connection || connection.length === 0 || !connection[0]) { + throw new Error("No workflow connection found"); + } + + return connection[0]; +} diff --git a/apps/mesh/src/web/components/details/workflow/index.tsx b/apps/mesh/src/web/components/details/workflow/index.tsx new file mode 100644 index 000000000..e741b045e --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/index.tsx @@ -0,0 +1,184 @@ +import { Spinner } from "@deco/ui/components/spinner.tsx"; +import { Workflow } from "@decocms/bindings/workflow"; +import { + useTrackingExecutionId, + useWorkflow, + useWorkflowActions, + WorkflowStoreProvider, +} from "@/web/components/details/workflow/stores/workflow"; +import { useWorkflowCollectionItem } from "./hooks"; +import { toast } from "@deco/ui/components/sonner.tsx"; +import { MonacoCodeEditor } from "./components/monaco-editor"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@deco/ui/components/resizable.js"; +import { Button } from "@deco/ui/components/button.js"; +import { Eye, X } from "lucide-react"; +import { WorkflowEditorHeader } from "./components/workflow-editor-header"; +import { WorkflowStepsCanvas } from "./components/workflow-steps-canvas"; +import { ToolSidebar } from "./components/tool-sidebar"; +import { StepDetailPanel } from "./components/step-detail-panel"; +import { ExecutionsList } from "./components/executions-list"; +import { useViewModeStore } from "./stores/view-mode"; +import { useCurrentStep } from "./stores/workflow"; + +export interface WorkflowDetailsViewProps { + itemId: string; + onBack: () => void; + onUpdate: (updates: Record) => Promise; +} + +export function WorkflowDetailsView({ + itemId, + onBack, +}: WorkflowDetailsViewProps) { + const { item, update } = useWorkflowCollectionItem(itemId); + + if (!item) { + return ( +
+ +
+ ); + } + + return ( + + { + try { + update(updates); + toast.success("Workflow updated successfully"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update workflow", + ); + throw error; + } + }} + /> + + ); +} + +interface WorkflowDetailsProps { + onBack: () => void; + onUpdate: (updates: Record) => Promise; +} + +function WorkflowCode({ + workflow, + onUpdate, +}: { + workflow: Workflow; + onUpdate: (updates: Record) => Promise; +}) { + const { setWorkflow } = useWorkflowActions(); + const wf = { + title: workflow.title, + description: workflow.description, + steps: workflow.steps, + }; + return ( + { + const parsed = JSON.parse(code); + setWorkflow({ + ...workflow, + ...parsed, + }); + onUpdate(parsed); + }} + /> + ); +} + +function WorkflowDetails({ onBack, onUpdate }: WorkflowDetailsProps) { + const workflow = useWorkflow(); + const trackingExecutionId = useTrackingExecutionId(); + const { setTrackingExecutionId, setOriginalWorkflow } = useWorkflowActions(); + const { viewMode, showExecutionsList } = useViewModeStore(); + const currentStep = useCurrentStep(); + + const handleSave = async () => { + await onUpdate(workflow); + setOriginalWorkflow(workflow); + }; + + // Determine which sidebar to show + const isToolStep = currentStep && "toolName" in currentStep.action; + const hasToolSelected = + isToolStep && + "toolName" in currentStep.action && + currentStep.action.toolName; + const showStepDetail = hasToolSelected; + + return ( +
+ {/* Header */} + + + {/* Tracking Execution Bar */} + {trackingExecutionId && ( +
+
+
+ +
+
+ +
+ )} + + {/* Main Content */} +
+ {viewMode === "code" ? ( + + ) : ( + + {/* Steps Canvas Panel */} + + + + + + + {/* Right Panel - Executions List OR Step Config */} + + {showExecutionsList ? ( + + ) : showStepDetail ? ( + + ) : ( + + )} + + + )} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/workflow/stores/view-mode.ts b/apps/mesh/src/web/components/details/workflow/stores/view-mode.ts new file mode 100644 index 000000000..059e52474 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/stores/view-mode.ts @@ -0,0 +1,28 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type WorkflowViewMode = "visual" | "code"; + +interface ViewModeState { + viewMode: WorkflowViewMode; + setViewMode: (mode: WorkflowViewMode) => void; + showExecutionsList: boolean; + setShowExecutionsList: (show: boolean) => void; + toggleExecutionsList: () => void; +} + +export const useViewModeStore = create()( + persist( + (set) => ({ + viewMode: "visual", + setViewMode: (mode) => set({ viewMode: mode }), + showExecutionsList: false, + setShowExecutionsList: (show) => set({ showExecutionsList: show }), + toggleExecutionsList: () => + set((state) => ({ showExecutionsList: !state.showExecutionsList })), + }), + { + name: "workflow-view-mode", + }, + ), +); diff --git a/apps/mesh/src/web/components/details/workflow/stores/workflow.tsx b/apps/mesh/src/web/components/details/workflow/stores/workflow.tsx new file mode 100644 index 000000000..e468ce98d --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/stores/workflow.tsx @@ -0,0 +1,482 @@ +import { createStore, StoreApi } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { shallow } from "zustand/vanilla/shallow"; +import { Workflow, DEFAULT_CODE_STEP } from "@decocms/bindings/workflow"; +import { Step, ToolCallAction, CodeAction } from "@decocms/bindings/workflow"; +import { createContext, useContext, useState } from "react"; +import { jsonSchemaToTypeScript } from "../typescript-to-json-schema"; + +type CurrentStepTab = "input" | "output" | "action" | "executions"; +export type StepType = "tool" | "code"; + +interface State { + originalWorkflow: Workflow; + isAddingStep: boolean; + /** The type of step being added (set when user clicks add button) */ + addingStepType: StepType | null; + /** Selected parent steps for multi-selection (used for code steps) */ + selectedParentSteps: string[]; + /** Previous tool info when replacing (for back button) */ + replacingToolInfo: { connectionId: string; toolName: string } | null; + workflow: Workflow; + trackingExecutionId: string | undefined; + currentStepTab: CurrentStepTab; + currentStepName: string | undefined; +} + +interface Actions { + setToolAction: (toolAction: ToolCallAction) => void; + appendStep: ({ step, type }: { step?: Step; type: StepType }) => void; + setIsAddingStep: (isAddingStep: boolean) => void; + deleteStep: (stepName: string) => void; + duplicateStep: (stepName: string) => void; + setCurrentStepName: (stepName: string | undefined) => void; + updateStep: (stepName: string, updates: Partial) => void; + setTrackingExecutionId: (executionId: string | undefined) => void; + setCurrentStepTab: (currentStepTab: CurrentStepTab) => void; + resetToOriginalWorkflow: () => void; + /** Start the add step flow - user selects type first */ + startAddingStep: (type: StepType) => void; + /** Cancel the add step flow */ + cancelAddingStep: () => void; + /** Add new tool step */ + addToolStep: () => void; + /** Toggle selection of a parent step (for code steps multi-selection) */ + toggleParentStepSelection: (stepName: string) => void; + /** Confirm adding a code step with selected parent steps */ + confirmAddCodeStep: () => void; + setOriginalWorkflow: (workflow: Workflow) => void; + setWorkflow: (workflow: Workflow) => void; + /** Start replacing tool (store previous values for back button) */ + startReplacingTool: (connectionId: string, toolName: string) => void; + /** Cancel replacing tool (clear stored values) */ + cancelReplacingTool: () => void; +} + +interface Store extends State { + actions: Actions; +} + +function generateUniqueName(baseName: string, existingSteps: Step[]): string { + const trimmedName = baseName.trim(); + const exists = existingSteps.some( + (s) => s.name.toLowerCase() === trimmedName.toLowerCase(), + ); + if (!exists) return trimmedName; + return `${trimmedName}_${Math.random().toString(36).substring(2, 6)}`; +} + +/** + * Replace the Input interface in code with a new interface definition. + * If no Input interface exists, prepends the new one. + * Handles nested braces in the interface body. + */ +function replaceInputInterface( + code: string, + newInputInterface: string, +): string { + // Find "interface Input {" and then match balanced braces + const startMatch = code.match(/interface\s+Input\s*\{/); + if (!startMatch || startMatch.index === undefined) { + // No existing Input interface, prepend the new one + return `${newInputInterface}\n\n${code.trimStart()}`; + } + + const startIdx = startMatch.index; + const braceStart = startIdx + startMatch[0].length - 1; // Position of opening { + + // Find the matching closing brace + let depth = 1; + let endIdx = braceStart + 1; + while (endIdx < code.length && depth > 0) { + if (code[endIdx] === "{") depth++; + else if (code[endIdx] === "}") depth--; + endIdx++; + } + + // Replace the entire interface (from "interface Input" to closing "}") + return code.slice(0, startIdx) + newInputInterface + code.slice(endIdx); +} + +function createDefaultStep(type: StepType, index: number): Step { + switch (type) { + case "tool": + return { + input: {}, + action: { toolName: "" }, + outputSchema: {}, + name: `Step_${index + 1}`, + }; + case "code": + return { ...DEFAULT_CODE_STEP, name: `Step_${index + 1}` }; + default: + throw new Error(`Invalid step type: ${type}`); + } +} + +const WorkflowStoreContext = createContext | null>(null); +const createWorkflowStore = (initialState: State) => { + return createStore()( + persist( + (set) => ({ + ...initialState, + actions: { + setIsAddingStep: (isAddingStep) => + set((state) => ({ + ...state, + isAddingStep: isAddingStep, + })), + + setCurrentStepTab: (currentStepTab) => + set((state) => ({ + ...state, + currentStepTab: currentStepTab, + })), + setToolAction: (toolAction) => + set((state) => ({ + workflow: { + ...state.workflow, + steps: state.workflow.steps.map((step) => + "toolName" in step.action && + step.action.toolName !== toolAction.toolName + ? { ...step, action: toolAction } + : step, + ), + }, + })), + appendStep: ({ step, type }) => + set((state) => { + const newStep = + step ?? createDefaultStep(type, state.workflow.steps.length); + const existingName = state.workflow.steps.find( + (s) => s.name === newStep.name, + ); + const newName = existingName + ? `${newStep.name} ${ + parseInt( + existingName.name.split(" ").pop() ?? + Math.random().toString(36).substring(2, 15), + ) + 1 + }` + : newStep.name; + return { + workflow: { + ...state.workflow, + steps: [ + ...state.workflow.steps, + { ...newStep, name: newName }, + ], + }, + }; + }), + + deleteStep: (stepName) => + set((state) => ({ + workflow: { + ...state.workflow, + steps: state.workflow.steps.filter( + (step) => step.name !== stepName, + ), + }, + })), + duplicateStep: (stepName) => + set((state) => { + const stepIndex = state.workflow.steps.findIndex( + (step) => step.name === stepName, + ); + if (stepIndex === -1) return state; + + const stepToDuplicate = state.workflow.steps[stepIndex]; + if (!stepToDuplicate) return state; + const duplicatedStep: Step = { + ...stepToDuplicate, + name: generateUniqueName( + stepToDuplicate.name, + state.workflow.steps, + ), + }; + + const newSteps = [...state.workflow.steps]; + newSteps.splice(stepIndex + 1, 0, duplicatedStep); + + return { + workflow: { + ...state.workflow, + steps: newSteps, + }, + currentStepName: duplicatedStep.name, + }; + }), + setCurrentStepName: (stepName) => + set((state) => ({ + ...state, + currentStepName: stepName, + })), + updateStep: (stepName, updates) => + set((state) => ({ + ...state, + workflow: { + ...state.workflow, + steps: state.workflow.steps.map((step) => + step.name === stepName ? { ...step, ...updates } : step, + ), + }, + })), + setTrackingExecutionId: (executionId) => + set((state) => ({ + ...state, + trackingExecutionId: executionId, + })), + resetToOriginalWorkflow: () => + set((state) => ({ + ...state, + workflow: state.originalWorkflow, + })), + startAddingStep: (type: StepType) => + set((state) => ({ + ...state, + isAddingStep: true, + addingStepType: type, + })), + cancelAddingStep: () => + set((state) => ({ + ...state, + isAddingStep: false, + addingStepType: null, + selectedParentSteps: [], + })), + toggleParentStepSelection: (stepName: string) => + set((state) => { + const isSelected = state.selectedParentSteps.includes(stepName); + return { + ...state, + selectedParentSteps: isSelected + ? state.selectedParentSteps.filter((s) => s !== stepName) + : [...state.selectedParentSteps, stepName], + }; + }), + confirmAddCodeStep: () => + set((state) => { + const { selectedParentSteps, addingStepType, workflow } = state; + if (addingStepType !== "code" || selectedParentSteps.length === 0) + return state; + + // Build input object with references to all selected parent steps + const input: Record = {}; + for (const stepName of selectedParentSteps) { + input[stepName] = `@${stepName}`; + } + + // Combine outputSchemas from all selected parent steps + const combinedProperties: Record = {}; + for (const stepName of selectedParentSteps) { + const parentStep = workflow.steps.find( + (s) => s.name === stepName, + ); + if (parentStep?.outputSchema) { + combinedProperties[stepName] = parentStep.outputSchema; + } + } + + const combinedSchema: Record | undefined = + Object.keys(combinedProperties).length > 0 + ? { + type: "object", + properties: combinedProperties, + required: Object.keys(combinedProperties), + } + : undefined; + + // Create the new code step + let newStep = createDefaultStep( + "code", + Number((Math.random() * 1000000).toFixed(0)), + ); + + newStep = { + ...newStep, + input, + }; + + // Inject the combined Input interface into the code + if (combinedSchema) { + const inputInterface = jsonSchemaToTypeScript( + combinedSchema, + "Input", + ); + const codeAction = newStep.action as CodeAction; + const updatedCode = replaceInputInterface( + codeAction.code, + inputInterface, + ); + newStep = { + ...newStep, + action: { ...codeAction, code: updatedCode }, + }; + } + + const newName = generateUniqueName(newStep.name, workflow.steps); + + return { + ...state, + isAddingStep: false, + addingStepType: null, + selectedParentSteps: [], + workflow: { + ...workflow, + steps: [...workflow.steps, { ...newStep, name: newName }], + }, + currentStepName: newName, + }; + }), + addToolStep: () => + set((state) => { + // Create the new step + let newStep = createDefaultStep( + "tool", + Number((Math.random() * 1000000).toFixed(0)), + ); + + const newName = generateUniqueName( + newStep.name, + state.workflow.steps, + ); + + return { + ...state, + isAddingStep: false, + addingStepType: null, + workflow: { + ...state.workflow, + steps: [ + ...state.workflow.steps, + { ...newStep, name: newName }, + ], + }, + currentStepName: newName, + }; + }), + setOriginalWorkflow: (workflow) => + set((state) => ({ + ...state, + originalWorkflow: workflow, + })), + setWorkflow: (workflow) => + set((state) => ({ + ...state, + workflow: workflow, + })), + startReplacingTool: (connectionId, toolName) => + set((state) => ({ + ...state, + replacingToolInfo: { connectionId, toolName }, + })), + cancelReplacingTool: () => + set((state) => ({ + ...state, + replacingToolInfo: null, + })), + }, + }), + { + name: `workflow-store-${encodeURIComponent( + initialState.workflow.id, + ).slice(0, 200)}`, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + workflow: state.workflow, + trackingExecutionId: state.trackingExecutionId, + currentStepName: state.currentStepName, + currentStepTab: state.currentStepTab, + originalWorkflow: state.originalWorkflow, + isAddingStep: state.isAddingStep, + addingStepType: state.addingStepType, + selectedParentSteps: state.selectedParentSteps, + replacingToolInfo: state.replacingToolInfo, + }), + }, + ), + ); +}; + +export function WorkflowStoreProvider({ + children, + workflow, + trackingExecutionId, +}: { + children: React.ReactNode; + workflow: Workflow; + trackingExecutionId?: string; +}) { + const [store] = useState(() => + createWorkflowStore({ + originalWorkflow: workflow, + workflow, + isAddingStep: false, + addingStepType: null, + selectedParentSteps: [], + replacingToolInfo: null, + currentStepName: undefined, + trackingExecutionId, + currentStepTab: "input", + }), + ); + + return ( + + {children} + + ); +} +function useWorkflowStore( + selector: (state: Store) => T, + equalityFn?: (a: T, b: T) => boolean, +): T { + const store = useContext(WorkflowStoreContext); + if (!store) { + throw new Error( + "Missing WorkflowStoreProvider - refresh the page. If the error persists, please contact support.", + ); + } + return useStoreWithEqualityFn(store, selector, equalityFn ?? shallow); +} + +export function useWorkflow() { + return useWorkflowStore((state) => state.workflow); +} + +export function useWorkflowActions() { + return useWorkflowStore((state) => state.actions); +} + +export function useCurrentStepName() { + const steps = useWorkflowSteps(); + return useWorkflowStore((state) => state.currentStepName) ?? steps[0]?.name; +} + +export function useCurrentStep() { + const currentStepName = useCurrentStepName(); + const steps = useWorkflowSteps(); + const exact = steps.find((step) => step.name === currentStepName); + if (exact) return exact; + return steps[0]; +} + +export function useWorkflowSteps() { + return useWorkflow().steps; +} + +export function useIsDirty() { + const workflow = useWorkflow(); + const originalWorkflow = useWorkflowStore((state) => state.originalWorkflow); + return JSON.stringify(workflow) !== JSON.stringify(originalWorkflow); +} + +export function useTrackingExecutionId() { + return useWorkflowStore((state) => state.trackingExecutionId); +} + +export function useReplacingToolInfo() { + return useWorkflowStore((state) => state.replacingToolInfo); +} diff --git a/apps/mesh/src/web/components/details/workflow/typescript-to-json-schema.ts b/apps/mesh/src/web/components/details/workflow/typescript-to-json-schema.ts new file mode 100644 index 000000000..7b32598d7 --- /dev/null +++ b/apps/mesh/src/web/components/details/workflow/typescript-to-json-schema.ts @@ -0,0 +1,293 @@ +export interface JsonSchema { + type?: string; + properties?: Record; + items?: JsonSchema; + required?: string[]; + description?: string; + enum?: unknown[]; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + $ref?: string; + additionalProperties?: boolean | JsonSchema; + not?: JsonSchema; +} + +/** + * Convert a TypeScript type string to JSON Schema. + */ +function typeToJsonSchema(typeStr: string): JsonSchema { + const trimmed = typeStr.trim(); + + // Handle union types: A | B | C + if (trimmed.includes("|")) { + const parts = splitUnionOrIntersection(trimmed, "|"); + if (parts.length > 1) { + // Check if it's a simple nullable type: string | null + const nonNullParts = parts.filter( + (p) => p.trim() !== "null" && p.trim() !== "undefined", + ); + const hasNull = parts.some( + (p) => p.trim() === "null" || p.trim() === "undefined", + ); + + if (nonNullParts.length === 1 && nonNullParts[0] && hasNull) { + // Simple nullable type + const baseSchema = typeToJsonSchema(nonNullParts[0]); + return { anyOf: [baseSchema, { type: "null" }] }; + } + + return { anyOf: parts.map((p) => typeToJsonSchema(p.trim())) }; + } + } + + // Handle intersection types: A & B + if (trimmed.includes("&")) { + const parts = splitUnionOrIntersection(trimmed, "&"); + if (parts.length > 1) { + return { allOf: parts.map((p) => typeToJsonSchema(p.trim())) }; + } + } + + // Handle array types: Type[] or Array + if (trimmed.endsWith("[]")) { + const itemType = trimmed.slice(0, -2).trim(); + return { type: "array", items: typeToJsonSchema(itemType) }; + } + + const arrayMatch = trimmed.match(/^Array<(.+)>$/); + if (arrayMatch?.[1]) { + return { type: "array", items: typeToJsonSchema(arrayMatch[1]) }; + } + + // Handle Record + const recordMatch = trimmed.match(/^Record<\s*string\s*,\s*(.+)\s*>$/); + if (recordMatch?.[1]) { + return { + type: "object", + additionalProperties: typeToJsonSchema(recordMatch[1]), + }; + } + + // Handle Promise - unwrap the promise + const promiseMatch = trimmed.match(/^Promise<(.+)>$/); + if (promiseMatch?.[1]) { + return typeToJsonSchema(promiseMatch[1]); + } + + // Handle inline object types: { prop: Type; ... } + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return parseInlineObject(trimmed); + } + + // Handle string literal types: "value1" | "value2" + if (trimmed.startsWith('"') || trimmed.startsWith("'")) { + const literalValue = trimmed.slice(1, -1); + return { type: "string", enum: [literalValue] }; + } + + // Handle number literal + if (/^\d+$/.test(trimmed)) { + return { type: "number", enum: [parseInt(trimmed, 10)] }; + } + + // Primitive types + switch (trimmed) { + case "string": + return { type: "string" }; + case "number": + return { type: "number" }; + case "boolean": + return { type: "boolean" }; + case "null": + return { type: "null" }; + case "undefined": + return { type: "null" }; // JSON Schema doesn't have undefined + case "any": + case "unknown": + return {}; // Any type in JSON Schema + case "void": + return { type: "null" }; + case "never": + return { not: {} }; + case "object": + return { type: "object" }; + default: + // Unknown type, treat as any + return {}; + } +} + +/** + * Split a type string by union (|) or intersection (&) operators, + * respecting nested generics and objects. + */ +function splitUnionOrIntersection( + typeStr: string, + separator: "|" | "&", +): string[] { + const parts: string[] = []; + let current = ""; + let depth = 0; + let inString = false; + let stringChar = ""; + + for (let i = 0; i < typeStr.length; i++) { + const char = typeStr[i]; + const prevChar = typeStr[i - 1]; + + // Handle string literals + if ((char === '"' || char === "'") && prevChar !== "\\") { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + } + } + + if (!inString) { + if (char === "<" || char === "{" || char === "(") { + depth++; + } else if (char === ">" || char === "}" || char === ")") { + depth--; + } else if (char === separator && depth === 0) { + parts.push(current.trim()); + current = ""; + continue; + } + } + + current += char; + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; +} + +/** + * Parse an inline object type: { prop: Type; ... } + */ +function parseInlineObject(typeStr: string): JsonSchema { + const inner = typeStr.slice(1, -1).trim(); + if (!inner) { + return { type: "object" }; + } + + const properties: Record = {}; + const required: string[] = []; + + // Simple property parsing - handles basic cases + const propRegex = /(\w+)(\?)?:\s*([^;]+);?/g; + let match; + + while ((match = propRegex.exec(inner)) !== null) { + const [, name, optional, type] = match; + if (!name || !type) continue; + properties[name] = typeToJsonSchema(type.trim()); + if (!optional) { + required.push(name); + } + } + + return { + type: "object", + properties, + ...(required.length > 0 ? { required } : {}), + }; +} + +/** + * Convert a JSON Schema to a TypeScript interface string. + * + * @param schema - JSON Schema object + * @param typeName - Name for the generated interface (default: "Output") + * @returns TypeScript interface declaration string + */ +export function jsonSchemaToTypeScript( + schema: Record | null | undefined, + typeName: string = "Output", +): string { + if (!schema) return `interface ${typeName} {}`; + + function schemaToType(s: Record): string { + if (!s || typeof s !== "object") return "unknown"; + + const type = s.type as string | string[] | undefined; + + if (Array.isArray(type)) { + return type.map((t) => primitiveToTs(t)).join(" | "); + } + + switch (type) { + case "string": + if (s.enum) + return (s.enum as string[]).map((e) => `"${e}"`).join(" | "); + return "string"; + case "number": + case "integer": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + case "array": { + const items = s.items as Record | undefined; + return items ? `${schemaToType(items)}[]` : "unknown[]"; + } + case "object": + return objectToType(s); + default: + if (s.anyOf) + return (s.anyOf as Record[]) + .map(schemaToType) + .join(" | "); + if (s.oneOf) + return (s.oneOf as Record[]) + .map(schemaToType) + .join(" | "); + if (s.allOf) + return (s.allOf as Record[]) + .map(schemaToType) + .join(" & "); + return "unknown"; + } + } + + function primitiveToTs(t: string): string { + switch (t) { + case "string": + return "string"; + case "number": + case "integer": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + default: + return "unknown"; + } + } + + function objectToType(s: Record): string { + const props = s.properties as + | Record> + | undefined; + if (!props) return "Record"; + + const required = new Set((s.required as string[]) || []); + const lines = Object.entries(props).map(([key, value]) => { + const optional = required.has(key) ? "" : "?"; + const desc = value.description ? ` /** ${value.description} */\n` : ""; + return `${desc} ${key}${optional}: ${schemaToType(value)};`; + }); + + return `{\n${lines.join("\n")}\n}`; + } + + return `interface ${typeName} ${schemaToType(schema as Record)}`; +} diff --git a/apps/mesh/src/web/components/tiptap-mentions-input.tsx b/apps/mesh/src/web/components/tiptap-mentions-input.tsx new file mode 100644 index 000000000..511457fde --- /dev/null +++ b/apps/mesh/src/web/components/tiptap-mentions-input.tsx @@ -0,0 +1,472 @@ +import { + useEditor, + EditorContent, + ReactRenderer, + NodeViewWrapper, + ReactNodeViewRenderer, + type Editor, + type Content, +} from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Mention from "@tiptap/extension-mention"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { createStore } from "zustand"; +import { useStore } from "zustand"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@deco/ui/components/hover-card.tsx"; +import { createContext, useContext } from "react"; +import { useResolvedRefs } from "./details/workflow/components/tool-selector"; + +// --- Types --- + +export interface MentionItem { + id: string; + label: string; + children?: MentionItem[]; +} + +// --- Resolved Refs Context --- + +const ResolvedRefsContext = createContext | undefined>( + undefined, +); + +/** + * Resolve a reference like "Step_1.field.subfield" from the resolved refs map + */ +function resolveRefPath( + resolvedRefs: Record, + refId: string, +): unknown { + const parts = refId.split("."); + const rootKey = parts[0]; + if (!rootKey) return undefined; + + let value: unknown = resolvedRefs[rootKey]; + + for (let i = 1; i < parts.length && value !== undefined; i++) { + const part = parts[i]; + if (!part) continue; + if (typeof value === "object" && value !== null) { + value = (value as Record)[part]; + } else { + return undefined; + } + } + + return value; +} + +/** + * Custom NodeView for mentions that shows resolved values on hover + */ +function MentionNodeView({ + node, +}: { + node: { attrs: Record }; +}) { + const resolvedRefs = useContext(ResolvedRefsContext); + const mentionId = (node.attrs.id as string) ?? ""; + + const hasResolvedRefs = resolvedRefs !== undefined; + const resolvedValue = hasResolvedRefs + ? resolveRefPath(resolvedRefs, mentionId) + : undefined; + + const formattedValue = + resolvedValue !== undefined + ? typeof resolvedValue === "object" + ? JSON.stringify(resolvedValue, null, 2) + : String(resolvedValue) + : undefined; + + const mentionSpan = ( + + @{mentionId} + + ); + + if (!hasResolvedRefs || formattedValue === undefined) { + return {mentionSpan}; + } + + return ( + + + {mentionSpan} + +
+
+ @{mentionId} +
+
+              {formattedValue}
+            
+
+
+
+
+ ); +} + +/** + * Flatten the mentions tree into a flat array for suggestions and lookup + */ +function flattenMentions(items: MentionItem[]): MentionItem[] { + const result: MentionItem[] = []; + + function traverse(list: MentionItem[]) { + for (const item of list) { + // Only add leaf nodes (no children) as selectable mentions + if (!item.children?.length) { + result.push(item); + } + if (item.children) { + traverse(item.children); + } + } + } + + traverse(items); + return result; +} + +interface MentionState { + items: MentionItem[]; + selectedIndex: number; + command: ((item: MentionItem) => void) | null; +} + +// --- Module-level store (singleton per popup lifecycle) --- + +let mentionStore = createMentionStore(); + +function createMentionStore() { + return createStore(() => ({ + items: [], + selectedIndex: 0, + command: null, + })); +} + +function setItems(items: MentionItem[], command: (item: MentionItem) => void) { + mentionStore.setState({ items, command, selectedIndex: 0 }); +} + +function moveUp() { + const { items, selectedIndex } = mentionStore.getState(); + if (!items.length) return; + mentionStore.setState({ + selectedIndex: (selectedIndex - 1 + items.length) % items.length, + }); +} + +function moveDown() { + const { items, selectedIndex } = mentionStore.getState(); + if (!items.length) return; + mentionStore.setState({ selectedIndex: (selectedIndex + 1) % items.length }); +} + +function selectItem(): boolean { + const { items, selectedIndex, command } = mentionStore.getState(); + const item = items[selectedIndex]; + if (!item) return false; + command?.(item); + return true; +} + +function reset() { + mentionStore = createMentionStore(); +} + +// --- Mention List Component --- + +function MentionList() { + const items = useStore(mentionStore, (s) => s.items); + const selectedIndex = useStore(mentionStore, (s) => s.selectedIndex); + + if (!items.length) return null; + + return ( +
+
+ {items.map((item, index) => ( + + ))} +
+
+ ); +} + +// --- Suggestion Renderer --- + +interface SuggestionProps { + editor: Editor; + items: MentionItem[]; + command: (item: MentionItem) => void; + clientRect?: (() => DOMRect | null) | null; +} + +function suggestionRenderer() { + let component: ReactRenderer | null = null; + let popup: HTMLElement | null = null; + + return { + onStart: (props: SuggestionProps) => { + setItems(props.items, props.command); + component = new ReactRenderer(MentionList, { editor: props.editor }); + popup = document.createElement("div"); + + popup.style.cssText = "position:absolute;z-index:9999"; + const rect = props.clientRect?.(); + if (rect) { + popup.style.top = `${rect.bottom + window.scrollY + 4}px`; + popup.style.left = `${rect.left + window.scrollX}px`; + } + popup.appendChild(component.element); + document.body.appendChild(popup); + }, + onUpdate: (props: SuggestionProps) => { + setItems(props.items, props.command); + const rect = props.clientRect?.(); + if (rect && popup) { + popup.style.top = `${rect.bottom + window.scrollY + 4}px`; + popup.style.left = `${rect.left + window.scrollX}px`; + } + }, + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === "Escape") { + popup?.remove(); + component?.destroy(); + reset(); + return true; + } + if (event.key === "ArrowUp") { + moveUp(); + return true; + } + if (event.key === "ArrowDown") { + moveDown(); + return true; + } + if (event.key === "Enter") { + return selectItem(); + } + return false; + }, + onExit: () => { + popup?.remove(); + component?.destroy(); + reset(); + }, + }; +} + +// --- Helper Functions --- + +// Regex to match @mentions (e.g., @Initial_Step.query) +const MENTION_REGEX = /@([\w.]+)/g; + +/** + * Parse plain text value and convert @mentions into Tiptap JSON format. + */ +function parseValueToTiptapContent( + value: string | undefined, + mentions: MentionItem[], +): Content { + if (!value) return null; + + const flatMentions = flattenMentions(mentions); + const mentionMap = new Map(flatMentions.map((m) => [m.id, m])); + + // Check if there are any @mentions in the value + const hasRefs = MENTION_REGEX.test(value); + if (!hasRefs) return value; + + // Reset regex state + MENTION_REGEX.lastIndex = 0; + + type NodeContent = { + type: string; + text?: string; + attrs?: Record; + }; + const content: NodeContent[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = MENTION_REGEX.exec(value)) !== null) { + const mentionId = match[1] ?? ""; + const matchStart = match.index; + + if (matchStart > lastIndex) { + content.push({ type: "text", text: value.slice(lastIndex, matchStart) }); + } + + const mentionItem = mentionMap.get(mentionId); + if (mentionItem) { + content.push({ + type: "mention", + attrs: { id: mentionItem.id, label: mentionItem.label }, + }); + } else { + content.push({ type: "text", text: match[0] }); + } + + lastIndex = matchStart + match[0].length; + } + + if (lastIndex < value.length) { + content.push({ type: "text", text: value.slice(lastIndex) }); + } + + return { + type: "doc", + content: [ + { type: "paragraph", content: content.length > 0 ? content : undefined }, + ], + }; +} + +// --- Component --- + +interface MentionInputProps { + mentions: MentionItem[]; + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + className?: string; + readOnly?: boolean; + /** + * Map of resolved ref values for displaying on hover. + * Keys are root step names (e.g., "Step_1", "input"). + * When provided, hovering mentions will show their resolved values. + */ + resolvedRefs?: Record; +} + +export function MentionInput({ + mentions, + value, + onChange, + placeholder, + className, + readOnly, +}: MentionInputProps) { + const resolvedRefs = useResolvedRefs(); + const parsedContent = parseValueToTiptapContent(value, mentions); + + // Use custom NodeView when we have resolvedRefs to enable hover tooltips + const useCustomNodeView = resolvedRefs !== undefined; + + const editor = useEditor({ + content: parsedContent, + editable: !readOnly, + extensions: [ + StarterKit.configure({ + heading: false, + blockquote: false, + codeBlock: false, + horizontalRule: false, + hardBreak: false, + }), + Mention.configure({ + HTMLAttributes: { + class: "bg-primary/20 text-primary px-1 rounded font-medium", + }, + renderText: ({ node }) => `@${node.attrs.id}`, + renderHTML: ({ options, node }) => [ + "span", + options.HTMLAttributes, + `@${node.attrs.id}`, + ], + suggestion: { + items: ({ query }: { query: string }) => { + const flat = flattenMentions(mentions); + return flat.filter( + (m) => + m.label.toLowerCase().includes(query.toLowerCase()) || + m.id.toLowerCase().includes(query.toLowerCase()), + ); + }, + render: suggestionRenderer, + command: ({ editor, range, props }) => { + editor + .chain() + .focus() + .insertContentAt(range, [{ type: "mention", attrs: props }]) + .run(); + }, + }, + }).extend( + useCustomNodeView + ? { + addNodeView() { + return ReactNodeViewRenderer(MentionNodeView); + }, + } + : {}, + ), + ], + editorProps: { + attributes: { + class: + "prose prose-sm max-w-none focus:outline-none w-full min-h-[20px]", + "data-placeholder": placeholder ?? "", + }, + handleKeyDown: (_view, event) => { + if (event.key === "Enter") { + event.preventDefault(); + return true; + } + return false; + }, + }, + onUpdate: ({ editor }) => onChange?.(editor.getText().trim()), + }); + + const content = ( +
+ +
+ ); + + // Wrap with context provider if we have resolved refs + if (resolvedRefs !== undefined) { + return ( + + {content} + + ); + } + + return content; +} diff --git a/apps/mesh/src/web/hooks/use-binding.ts b/apps/mesh/src/web/hooks/use-binding.ts index 4c7537255..f0825543c 100644 --- a/apps/mesh/src/web/hooks/use-binding.ts +++ b/apps/mesh/src/web/hooks/use-binding.ts @@ -9,12 +9,18 @@ import { LANGUAGE_MODEL_BINDING } from "@decocms/bindings/llm"; import { MCP_BINDING } from "@decocms/bindings/mcp"; import { convertJsonSchemaToZod } from "zod-from-json-schema"; import type { ConnectionEntity } from "@/tools/connection/schema"; +import { + WORKFLOW_BINDING, + WORKFLOW_EXECUTION_BINDING, +} from "@decocms/bindings/workflow"; /** * Map of well-known binding names to their definitions */ const BUILTIN_BINDINGS: Record = { LLMS: LANGUAGE_MODEL_BINDING, + WORKFLOW: WORKFLOW_BINDING, + WORKFLOW_EXECUTION: WORKFLOW_EXECUTION_BINDING, ASSISTANTS: ASSISTANTS_BINDING, MCP: MCP_BINDING, }; @@ -135,7 +141,6 @@ export function useBindingConnections({ if (!binding) { return undefined; } - if (typeof binding === "string") { const upperBinding = binding.toUpperCase(); const builtinBinding = BUILTIN_BINDINGS[upperBinding]; diff --git a/apps/mesh/src/web/hooks/use-mcp.ts b/apps/mesh/src/web/hooks/use-mcp.ts index 440bfa45a..3a0cac693 100644 --- a/apps/mesh/src/web/hooks/use-mcp.ts +++ b/apps/mesh/src/web/hooks/use-mcp.ts @@ -12,6 +12,11 @@ export interface McpTool { properties?: Record; required?: string[]; }; + outputSchema?: { + type: string; + properties?: Record; + required?: string[]; + }; } /** diff --git a/apps/mesh/src/web/hooks/use-tool-call.ts b/apps/mesh/src/web/hooks/use-tool-call.ts index 23eab18ba..2406d7117 100644 --- a/apps/mesh/src/web/hooks/use-tool-call.ts +++ b/apps/mesh/src/web/hooks/use-tool-call.ts @@ -6,6 +6,9 @@ */ import { + Query, + useMutation, + useQuery, useSuspenseQuery, UseSuspenseQueryOptions, } from "@tanstack/react-query"; @@ -25,6 +28,17 @@ export interface UseToolCallOptions toolInputParams: TInput; /** Scope to cache the tool call (connectionId for connection-scoped, locator for org/project-scoped) */ scope: string; + /** Cache time in milliseconds */ + staleTime?: number; + /** Refetch interval in milliseconds (false to disable) */ + refetchInterval?: + | number + | (( + query: Query, + ) => number | false) + | false; + /** Whether to enable the tool call */ + enabled?: boolean; } /** @@ -73,3 +87,55 @@ export function useToolCall({ }, }); } + +export interface UseToolCallMutationOptions { + toolCaller: ToolCaller; + toolName: string; +} +export function useToolCallMutation( + options: UseToolCallMutationOptions, +) { + const { toolCaller, toolName } = options; + + return useMutation({ + mutationFn: async (input: TInput) => { + const result = await toolCaller(toolName, input); + return result; + }, + onSuccess: (data) => { + console.log("tool call mutation success", data); + }, + onError: (error) => { + console.error("tool call mutation error", error); + }, + }); +} + +export function useToolCallQuery( + options: UseToolCallOptions, +) { + const { + toolCaller, + toolName, + toolInputParams, + scope, + staleTime = 60_000, + refetchInterval, + enabled, + } = options; + + return useQuery({ + queryKey: KEYS.toolCall( + scope, + toolName, + JSON.stringify(toolInputParams ?? {}), + ), + queryFn: async () => { + const result = await toolCaller(toolName, toolInputParams ?? {}); + return result as TOutput; + }, + enabled, + staleTime, + refetchInterval, + }); +} diff --git a/apps/mesh/src/web/lib/locator.ts b/apps/mesh/src/web/lib/locator.ts index 02be15144..f504eff7e 100644 --- a/apps/mesh/src/web/lib/locator.ts +++ b/apps/mesh/src/web/lib/locator.ts @@ -15,7 +15,7 @@ export const ORG_ADMIN_PROJECT_SLUG = "org-admin"; export const Locator = { from({ org, project }: LocatorStructured): ProjectLocator { - if (org.includes("/") || project.includes("/")) { + if (org?.includes("/") || project.includes("/")) { throw new Error("Org or project cannot contain slashes"); } diff --git a/apps/mesh/src/web/routes/orgs/collection-detail.tsx b/apps/mesh/src/web/routes/orgs/collection-detail.tsx index 2b894c9d3..318008c16 100644 --- a/apps/mesh/src/web/routes/orgs/collection-detail.tsx +++ b/apps/mesh/src/web/routes/orgs/collection-detail.tsx @@ -7,6 +7,7 @@ import { EmptyState } from "@deco/ui/components/empty-state.tsx"; import { Loading01, Container } from "@untitledui/icons"; import { useParams, useRouter } from "@tanstack/react-router"; import { Suspense, type ComponentType } from "react"; +import { WorkflowDetailsView } from "@/web/components/details/workflow/index.tsx"; interface CollectionDetailsProps { itemId: string; @@ -19,6 +20,7 @@ const WELL_KNOWN_VIEW_DETAILS: Record< string, ComponentType > = { + workflow: WorkflowDetailsView, assistant: AssistantDetailsView, }; diff --git a/apps/mesh/src/web/utils/constants.ts b/apps/mesh/src/web/utils/constants.ts index 96796cf32..19e47a7fc 100644 --- a/apps/mesh/src/web/utils/constants.ts +++ b/apps/mesh/src/web/utils/constants.ts @@ -14,6 +14,7 @@ export type JsonSchema = { description?: string; enum?: string[]; maxLength?: number; + anyOf?: JsonSchema[]; [key: string]: unknown; }; diff --git a/apps/mesh/vite.config.ts b/apps/mesh/vite.config.ts index 52c436bdb..b786018a8 100644 --- a/apps/mesh/vite.config.ts +++ b/apps/mesh/vite.config.ts @@ -7,6 +7,9 @@ import deco from "@decocms/vite-plugin"; export default defineConfig({ server: { port: 4000, + hmr: { + overlay: true, + }, }, clearScreen: false, logLevel: "warn", diff --git a/packages/bindings/src/well-known/language-model.ts b/packages/bindings/src/well-known/language-model.ts index 509cdaf50..2e6ee4c2e 100644 --- a/packages/bindings/src/well-known/language-model.ts +++ b/packages/bindings/src/well-known/language-model.ts @@ -230,7 +230,6 @@ export const LanguageModelMessageSchema = z.union([ export const LanguageModelPromptSchema = z .array(LanguageModelMessageSchema) .describe("A list of messages forming the prompt"); - /** * Language Model Call Options Schema * Based on LanguageModelV2CallOptions from @ai-sdk/provider diff --git a/packages/bindings/src/well-known/workflow.ts b/packages/bindings/src/well-known/workflow.ts index ab8cc00d7..c66851012 100644 --- a/packages/bindings/src/well-known/workflow.ts +++ b/packages/bindings/src/well-known/workflow.ts @@ -9,113 +9,135 @@ */ import { z } from "zod"; -import type { Binder } from "../core/binder"; +import { type Binder, bindingClient, type ToolBinder } from "../core/binder"; import { BaseCollectionEntitySchema, createCollectionBindings, } from "./collections"; - export const ToolCallActionSchema = z.object({ - connectionId: z.string().describe("Integration connection ID"), - toolName: z.string().describe("Name of the tool to call"), + toolName: z + .string() + .describe("Name of the tool to invoke on that connection"), + transformCode: z + .string() + .optional() + .describe(`Pure TypeScript function for data transformation of the tool call result. Must be a TypeScript file that declares the Output interface and exports a default function: \`interface Output { ... } export default async function(input): Output { ... }\` + The input will match with the tool call outputSchema. If transformCode is not provided, the tool call result will be used as the step output. + Providing an transformCode is recommended because it both allows you to transform the data and validate it against a JSON Schema - tools are ephemeral and may return unexpected data.`), }); export type ToolCallAction = z.infer; export const CodeActionSchema = z.object({ - code: z.string().describe("TypeScript code for pure data transformation"), + code: z.string().describe( + `Pure TypeScript function for data transformation. Useful to merge data from multiple steps and transform it. Must be a TypeScript file that declares the Output interface and exports a default function: \`interface Output { ... } export default async function(input): Output { ... }\` + The input is the resolved value of the references in the input field. Example: + { + "input": { + "name": "@Step_1.name", + "age": "@Step_2.age" + }, + "code": "export default function(input): Output { return { result: \`\${input.name} is \${input.age} years old.\` } }" + } + `, + ), }); export type CodeAction = z.infer; -export const SleepActionSchema = z.union([ - z.object({ - sleepMs: z.number().describe("Milliseconds to sleep"), - }), - z.object({ - sleepUntil: z.string().describe("ISO date string or @ref to sleep until"), - }), -]); export const WaitForSignalActionSchema = z.object({ signalName: z .string() - .describe("Name of the signal to wait for (must be unique per execution)"), - timeoutMs: z - .number() - .optional() - .describe("Maximum time to wait in milliseconds (default: no timeout)"), - description: z - .string() - .optional() - .describe("Human-readable description of what this signal is waiting for"), + .describe( + "Signal name to wait for (e.g., 'approval'). Execution pauses until SEND_SIGNAL is called with this name.", + ), }); export type WaitForSignalAction = z.infer; export const StepActionSchema = z.union([ - ToolCallActionSchema.describe( - "Call an external tool (non-deterministic, checkpointed)", - ), + ToolCallActionSchema.describe("Call an external tool via MCP connection. "), CodeActionSchema.describe( - "Pure TypeScript data transformation (deterministic, replayable)", + "Run pure TypeScript code for data transformation. Useful to merge data from multiple steps and transform it.", ), - SleepActionSchema.describe("Wait for time"), - WaitForSignalActionSchema.describe("Wait for external signal"), + // WaitForSignalActionSchema.describe( + // "Pause execution until an external signal is received (human-in-the-loop)", + // ), ]); export type StepAction = z.infer; + /** - * Step Schema - Unified schema for all step types - * - * Step types: - * - tool: Call external service via MCP (non-deterministic, checkpointed) - * - transform: Pure TypeScript data transformation (deterministic, replayable) - * - sleep: Wait for time - * - waitForSignal: Block until external signal (human-in-the-loop) + * Step Config Schema - Optional configuration for retry, timeout, and looping */ -export const StepSchema = z.object({ - name: z.string().min(1).describe("Unique step name within workflow"), - action: StepActionSchema, - input: z - .record(z.unknown()) +export const StepConfigSchema = z.object({ + maxAttempts: z + .number() .optional() - .describe( - "Input object with @ref resolution or default values. Example: { 'user_id': '@input.user_id', 'product_id': '@input.product_id' }", - ), - config: z - .object({ - maxAttempts: z.number().default(3).describe("Maximum retry attempts"), - backoffMs: z - .number() - .default(1000) - .describe("Initial backoff in milliseconds"), - timeoutMs: z.number().default(10000).describe("Timeout in milliseconds"), - }) + .describe("Max retry attempts on failure (default: 1, no retries)"), + backoffMs: z + .number() .optional() - .describe("Step configuration (max attempts, backoff, timeout)"), + .describe("Initial delay between retries in ms (doubles each attempt)"), + timeoutMs: z + .number() + .optional() + .describe("Max execution time in ms before step fails (default: 30000)"), }); - -export type Step = z.infer; +export type StepConfig = z.infer; /** - * Trigger Schema - Fire another workflow when execution completes + * Step Schema - A single unit of work in a workflow + * + * Action types: + * - Tool call: Invoke an external tool via MCP connection + * - Code: Run pure TypeScript for data transformation + * - Wait for signal: Pause until external input (human-in-the-loop) + * + * Data flow uses @ref syntax: + * - @input.field → workflow input + * - @stepName.field → output from a previous step */ -export const TriggerSchema = z.object({ - /** - * Target workflow ID to execute - */ - workflowId: z.string().describe("Target workflow ID to trigger"), - - /** - * Input for the new execution (uses @refs like step inputs) - * Maps output data to workflow input fields. - * - * If any @ref doesn't resolve (property missing), this trigger is SKIPPED. - */ + +type JsonSchema = { + type?: string; + properties?: Record; + required?: string[]; + description?: string; + additionalProperties?: boolean; + additionalItems?: boolean; + items?: JsonSchema; +}; +const JsonSchemaSchema: z.ZodType = z.lazy(() => + z.object({ + type: z.string().optional(), + properties: z.record(z.unknown()).optional(), + required: z.array(z.string()).optional(), + description: z.string().optional(), + additionalProperties: z.boolean().optional(), + additionalItems: z.boolean().optional(), + items: JsonSchemaSchema.optional(), + }), +); + +export const StepSchema = z.object({ + name: z + .string() + .min(1) + .describe( + "Unique identifier for this step. Other steps reference its output as @name.field", + ), + description: z.string().optional().describe("What this step does"), + action: StepActionSchema, input: z .record(z.unknown()) + .optional() .describe( - "Input mapping with @refs from current workflow output. Example: { 'user_id': '@stepName.output.user_id' }", + "Data passed to the action. Use @ref for dynamic values: @input.field (workflow input), @stepName.field (previous step output), @item/@index (loop context). Example: { 'userId': '@input.user_id', 'data': '@fetch.result' }", ), + outputSchema: JsonSchemaSchema.optional().describe( + "Optional JSON Schema describing the expected output of the step.", + ), + config: StepConfigSchema.optional().describe("Retry and timeout settings"), }); -export type Trigger = z.infer; +export type Step = z.infer; /** * Workflow Execution Status @@ -128,8 +150,8 @@ export type Trigger = z.infer; */ const WorkflowExecutionStatusEnum = z - .enum(["pending", "running", "completed", "cancelled"]) - .default("pending"); + .enum(["enqueued", "running", "success", "error", "cancelled"]) + .default("enqueued"); export type WorkflowExecutionStatus = z.infer< typeof WorkflowExecutionStatusEnum >; @@ -140,38 +162,42 @@ export type WorkflowExecutionStatus = z.infer< * Includes lock columns and retry tracking. */ export const WorkflowExecutionSchema = BaseCollectionEntitySchema.extend({ - workflow_id: z.string(), - status: WorkflowExecutionStatusEnum, - input: z.record(z.unknown()).optional(), - output: z.unknown().optional(), - parent_execution_id: z.string().nullish(), - completed_at_epoch_ms: z.number().nullish(), - locked_until_epoch_ms: z.number().nullish(), - lock_id: z.string().nullish(), - retry_count: z.number().default(0), - max_retries: z.number().default(10), - error: z.string().nullish(), + steps: z + .array(StepSchema) + .describe("Steps that make up the workflow") + .describe("Workflow that was executed"), + gateway_id: z + .string() + .describe("ID of the gateway that will be used to execute the workflow"), + status: WorkflowExecutionStatusEnum.describe( + "Current status of the workflow execution", + ), + input: z.record(z.unknown()).optional().describe("Input data for the workflow execution"), + output: z.unknown().optional().describe("Output data for the workflow execution"), + completed_at_epoch_ms: z + .number() + .nullish() + .describe("Timestamp of when the workflow execution completed"), + start_at_epoch_ms: z + .number() + .nullish() + .describe("Timestamp of when the workflow execution started or will start"), + timeout_ms: z + .number() + .nullish() + .describe("Timeout in milliseconds for the workflow execution"), + deadline_at_epoch_ms: z + .number() + .nullish() + .describe( + "Deadline for the workflow execution - when the workflow execution will be cancelled if it is not completed. This is read-only and is set by the workflow engine when an execution is created.", + ), + error: z + .unknown() + .describe("Error that occurred during the workflow execution"), }); export type WorkflowExecution = z.infer; -/** - * Execution Step Result Schema - * - * Includes attempt tracking and error history. - */ -export const WorkflowExecutionStepResultSchema = - BaseCollectionEntitySchema.extend({ - execution_id: z.string(), - step_id: z.string(), - - input: z.record(z.unknown()).nullish(), - output: z.unknown().nullish(), // Can be object or array (forEach steps produce arrays) - error: z.string().nullish(), - completed_at_epoch_ms: z.number().nullish(), - }); -export type WorkflowExecutionStepResult = z.infer< - typeof WorkflowExecutionStepResultSchema ->; /** * Event Type Enum * @@ -216,29 +242,35 @@ export const WorkflowEventSchema = BaseCollectionEntitySchema.extend({ export type WorkflowEvent = z.infer; /** - * Workflow entity schema for workflows - * Extends BaseCollectionEntitySchema with workflow-specific fields - * Base schema already includes: id, title, created_at, updated_at, created_by, updated_by + * Workflow Schema - A sequence of steps that execute with data flowing between them + * + * Key concepts: + * - Steps run in parallel unless they reference each other via @ref + * - Use @ref to wire data: @input.field, @stepName.field, @item (in loops) + * - Execution order is auto-determined from @ref dependencies + * + * Example: 2 parallel fetches + 1 merge step + * { + * "title": "Fetch and Merge", + * "steps": [ + * { "name": "fetch_users", "action": { "connectionId": "api", "toolName": "getUsers" } }, + * { "name": "fetch_orders", "action": { "connectionId": "api", "toolName": "getOrders" } }, + * { "name": "merge", "action": { "code": "..." }, "input": { "users": "@fetch_users.data", "orders": "@fetch_orders.data" } } + * ] + * } + * → fetch_users and fetch_orders run in parallel; merge waits for both */ export const WorkflowSchema = BaseCollectionEntitySchema.extend({ - description: z.string().optional().describe("Workflow description"), + description: z + .string() + .optional() + .describe("Human-readable summary of what this workflow does"), - /** - * Steps organized into phases. - * - Phases execute sequentially - * - Steps within a phase execute in parallel - */ steps: z - .array(z.array(StepSchema)) - .describe("2D array: phases (sequential) containing steps (parallel)"), - - /** - * Triggers to fire when execution completes successfully - */ - triggers: z - .array(TriggerSchema) - .optional() - .describe("Workflows to trigger on completion"), + .array(StepSchema) + .describe( + "Ordered list of steps. Execution order is auto-determined by @ref dependencies: steps with no @ref dependencies run in parallel; steps referencing @stepName wait for that step to complete.", + ), }); export type Workflow = z.infer; @@ -254,29 +286,95 @@ export const WORKFLOWS_COLLECTION_BINDING = createCollectionBindings( WorkflowSchema, ); -export const WORKFLOW_EXECUTIONS_COLLECTION_BINDING = createCollectionBindings( - "workflow_execution", - WorkflowExecutionSchema, - { - readOnly: true, +const DEFAULT_STEP_CONFIG: StepConfig = { + maxAttempts: 1, + timeoutMs: 30000, +}; + +// export const DEFAULT_WAIT_FOR_SIGNAL_STEP: Omit = { +// action: { +// signalName: "approve_output", +// }, +// outputSchema: { +// type: "object", +// properties: { +// approved: { +// type: "boolean", +// description: "Whether the output was approved", +// }, +// }, +// }, +// }; +export const DEFAULT_TOOL_STEP: Omit = { + action: { + toolName: "LLM_DO_GENERATE", + transformCode: ` + interface Input { + + } + export default function(input) { return input.result }`, + }, + input: { + modelId: "anthropic/claude-4.5-haiku", + prompt: "Write a haiku about the weather.", }, -); -export const WORKFLOW_STEP_RESULTS_COLLECTION_BINDING = - createCollectionBindings( - "workflow_execution_step_results", - WorkflowExecutionStepResultSchema, - { - readOnly: true, + config: DEFAULT_STEP_CONFIG, + outputSchema: { + type: "object", + properties: { + result: { + type: "string", + description: "The result of the step", + }, }, - ); + }, +}; +export const DEFAULT_CODE_STEP: Step = { + name: "Initial Step", + action: { + code: ` + interface Input { + example: string; + } -export const WORKFLOW_EVENTS_COLLECTION_BINDING = createCollectionBindings( - "workflow_events", - WorkflowEventSchema, - { - readOnly: true, + interface Output { + result: unknown; + } + + export default async function(input: Input): Promise { + return { + result: input.example + } + }`, }, + config: DEFAULT_STEP_CONFIG, + outputSchema: { + type: "object", + properties: { + result: { + type: "string", + description: "The result of the step", + }, + }, + required: ["result"], + description: + "The output of the step. This is a JSON Schema describing the expected output of the step.", + }, +}; + +export const createDefaultWorkflow = (id?: string): Workflow => ({ + id: id || crypto.randomUUID(), + title: "Default Workflow", + description: "The default workflow for the toolkit", + steps: [DEFAULT_CODE_STEP], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), +}); + +export const WORKFLOW_EXECUTIONS_COLLECTION_BINDING = createCollectionBindings( + "workflow_execution", + WorkflowExecutionSchema, ); /** @@ -289,9 +387,275 @@ export const WORKFLOW_EVENTS_COLLECTION_BINDING = createCollectionBindings( * - COLLECTION_WORKFLOW_LIST: List available workflows with their configurations * - COLLECTION_WORKFLOW_GET: Get a single workflow by ID (includes steps and triggers) */ -export const WORKFLOWS_BINDING = [ +export const WORKFLOW_COLLECTIONS_BINDINGS = [ ...WORKFLOWS_COLLECTION_BINDING, ...WORKFLOW_EXECUTIONS_COLLECTION_BINDING, - ...WORKFLOW_STEP_RESULTS_COLLECTION_BINDING, - ...WORKFLOW_EVENTS_COLLECTION_BINDING, ] as const satisfies Binder; + +export const WORKFLOW_BINDING = [ + ...WORKFLOW_COLLECTIONS_BINDINGS, +] satisfies ToolBinder[]; + +export const WorkflowBinding = bindingClient(WORKFLOW_BINDING); + +export const WORKFLOW_EXECUTION_BINDING = createCollectionBindings( + "workflow_execution", + WorkflowExecutionSchema, +); + +/** + * DAG (Directed Acyclic Graph) utilities for workflow step execution + * + * Pure TypeScript functions for analyzing step dependencies and grouping + * steps into execution levels for parallel execution. + * + * Can be used in both frontend (visualization) and backend (execution). + */ + +/** + * Minimal step interface for DAG computation. + * This allows the DAG utilities to work with any step-like object. + */ +export interface DAGStep { + name: string; + input?: unknown; +} + +/** + * Extract all @ref references from a value recursively. + * Finds patterns like @stepName or @stepName.field + * + * @param input - Any value that might contain @ref strings + * @returns Array of unique reference names (without @ prefix) + */ +export function getAllRefs(input: unknown): string[] { + const refs: string[] = []; + + function traverse(value: unknown) { + if (typeof value === "string") { + const matches = value.match(/@(\w+)/g); + if (matches) { + refs.push(...matches.map((m) => m.substring(1))); // Remove @ prefix + } + } else if (Array.isArray(value)) { + value.forEach(traverse); + } else if (typeof value === "object" && value !== null) { + Object.values(value).forEach(traverse); + } + } + + traverse(input); + return [...new Set(refs)].sort(); // Dedupe and sort for consistent results +} + +/** + * Get the dependencies of a step (other steps it references). + * Only returns dependencies that are actual step names (filters out built-ins like "item", "index", "input"). + * + * @param step - The step to analyze + * @param allStepNames - Set of all step names in the workflow + * @returns Array of step names this step depends on + */ +export function getStepDependencies( + step: DAGStep, + allStepNames: Set, +): string[] { + const deps: string[] = []; + + function traverse(value: unknown) { + if (typeof value === "string") { + // Match @stepName or @stepName.something patterns + const matches = value.match(/@(\w+)/g); + if (matches) { + for (const match of matches) { + const refName = match.substring(1); // Remove @ + // Only count as dependency if it references another step + // (not "item", "index", "input" from forEach or workflow input) + if (allStepNames.has(refName)) { + deps.push(refName); + } + } + } + } else if (Array.isArray(value)) { + value.forEach(traverse); + } else if (typeof value === "object" && value !== null) { + Object.values(value).forEach(traverse); + } + } + + traverse(step.input); + return [...new Set(deps)]; +} + +/** + * Build edges for the DAG: [fromStep, toStep][] + */ +export function buildDagEdges(steps: Step[]): [string, string][] { + const stepNames = new Set(steps.map((s) => s.name)); + const edges: [string, string][] = []; + + for (const step of steps) { + const deps = getStepDependencies(step, stepNames); + for (const dep of deps) { + edges.push([dep, step.name]); + } + } + + return edges; +} + +/** + * Compute topological levels for all steps. + * Level 0 = no dependencies on other steps + * Level N = depends on at least one step at level N-1 + * + * @param steps - Array of steps to analyze + * @returns Map from step name to level number + */ +export function computeStepLevels( + steps: T[], +): Map { + const stepNames = new Set(steps.map((s) => s.name)); + const levels = new Map(); + + // Build dependency map + const depsMap = new Map(); + for (const step of steps) { + depsMap.set(step.name, getStepDependencies(step, stepNames)); + } + + // Compute level for each step (with memoization) + function getLevel(stepName: string, visited: Set): number { + if (levels.has(stepName)) return levels.get(stepName)!; + if (visited.has(stepName)) return 0; // Cycle detection + + visited.add(stepName); + const deps = depsMap.get(stepName) || []; + + if (deps.length === 0) { + levels.set(stepName, 0); + return 0; + } + + const maxDepLevel = Math.max(...deps.map((d) => getLevel(d, visited))); + const level = maxDepLevel + 1; + levels.set(stepName, level); + return level; + } + + for (const step of steps) { + getLevel(step.name, new Set()); + } + + return levels; +} + +/** + * Group steps by their execution level. + * Steps at the same level have no dependencies on each other and can run in parallel. + * + * @param steps - Array of steps to group + * @returns Array of step arrays, where index is the level + */ +export function groupStepsByLevel(steps: T[]): T[][] { + const levels = computeStepLevels(steps); + const maxLevel = Math.max(...Array.from(levels.values()), -1); + + const grouped: T[][] = []; + for (let level = 0; level <= maxLevel; level++) { + const stepsAtLevel = steps.filter((s) => levels.get(s.name) === level); + if (stepsAtLevel.length > 0) { + grouped.push(stepsAtLevel); + } + } + + return grouped; +} + +/** + * Get the dependency signature for a step (for grouping steps with same deps). + * + * @param step - The step to get signature for + * @returns Comma-separated sorted list of dependencies + */ +export function getRefSignature(step: DAGStep): string { + const inputRefs = getAllRefs(step.input); + const allRefs = [...new Set([...inputRefs])].sort(); + return allRefs.join(","); +} + +/** + * Build a dependency graph for visualization. + * Returns edges as [fromStep, toStep] pairs. + * + * @param steps - Array of steps + * @returns Array of [source, target] pairs representing edges + */ +export function buildDependencyEdges( + steps: T[], +): [string, string][] { + const stepNames = new Set(steps.map((s) => s.name)); + const edges: [string, string][] = []; + + for (const step of steps) { + const deps = getStepDependencies(step, stepNames); + for (const dep of deps) { + edges.push([dep, step.name]); + } + } + + return edges; +} + +/** + * Validate that there are no cycles in the step dependencies. + * + * @param steps - Array of steps to validate + * @returns Object with isValid and optional error message + */ +export function validateNoCycles( + steps: T[], +): { isValid: boolean; error?: string } { + const stepNames = new Set(steps.map((s) => s.name)); + const depsMap = new Map(); + + for (const step of steps) { + depsMap.set(step.name, getStepDependencies(step, stepNames)); + } + + const visited = new Set(); + const recursionStack = new Set(); + + function hasCycle(stepName: string, path: string[]): string[] | null { + if (recursionStack.has(stepName)) { + return [...path, stepName]; + } + if (visited.has(stepName)) { + return null; + } + + visited.add(stepName); + recursionStack.add(stepName); + + const deps = depsMap.get(stepName) || []; + for (const dep of deps) { + const cycle = hasCycle(dep, [...path, stepName]); + if (cycle) return cycle; + } + + recursionStack.delete(stepName); + return null; + } + + for (const step of steps) { + const cycle = hasCycle(step.name, []); + if (cycle) { + return { + isValid: false, + error: `Circular dependency detected: ${cycle.join(" -> ")}`, + }; + } + } + + return { isValid: true }; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 2e81b3b2d..2ee028d64 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -9,7 +9,7 @@ "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@deco/mcp": "npm:@jsr/deco__mcp@0.7.8", - "@decocms/bindings": "1.0.1-alpha.23", + "@decocms/bindings": "file:/Users/pedrofranca/Documents/dev/decocms/mesh/packages/bindings", "@modelcontextprotocol/sdk": "1.20.2", "@ai-sdk/provider": "^2.0.0", "hono": "^4.10.7", diff --git a/packages/runtime/src/bindings.ts b/packages/runtime/src/bindings.ts index b7ce26f06..6c547b938 100644 --- a/packages/runtime/src/bindings.ts +++ b/packages/runtime/src/bindings.ts @@ -115,8 +115,7 @@ export const proxyConnectionForId = ( headers, }; }; - -const mcpClientForConnectionId = ( +export const mcpClientForConnectionId = ( connectionId: string, ctx: ClientContext, appName?: string, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index fa7a407d3..d95b5d8bd 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -24,7 +24,11 @@ export { type GetPromptResult, } from "./tools.ts"; import type { Binding } from "./wrangler.ts"; -export { proxyConnectionForId, BindingOf } from "./bindings.ts"; +export { + proxyConnectionForId, + BindingOf, + mcpClientForConnectionId, +} from "./bindings.ts"; export { type CORSOptions, type CORSOrigin } from "./cors.ts"; export { createMCPFetchStub, diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx index 3b80a5330..9109dd422 100644 --- a/packages/ui/src/components/accordion.tsx +++ b/packages/ui/src/components/accordion.tsx @@ -2,7 +2,7 @@ import type * as React from "react"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; -import { ChevronDown } from "@untitledui/icons"; +import { ChevronRight } from "@untitledui/icons"; import { cn } from "@deco/ui/lib/utils.ts"; @@ -35,13 +35,13 @@ function AccordionTrigger({ svg]:rotate-180", + "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 py-4 text-left text-sm font-medium transition-all outline-none hover:bg-accent/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-90", className, )} {...props} > {children} - + ); diff --git a/packages/ui/src/components/scroll-area.tsx b/packages/ui/src/components/scroll-area.tsx index 12ab863ed..58a07456b 100644 --- a/packages/ui/src/components/scroll-area.tsx +++ b/packages/ui/src/components/scroll-area.tsx @@ -8,8 +8,11 @@ import { cn } from "@deco/ui/lib/utils.ts"; function ScrollArea({ className, children, + hideScrollbar = false, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + hideScrollbar?: boolean; +}) { return (
{children}
- +
); @@ -31,8 +34,11 @@ function ScrollArea({ function ScrollBar({ className, orientation = "vertical", + hideScrollbar = false, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + hideScrollbar?: boolean; +}) { return (