From 9bbbe0adcb1206802e3bc273c5d236747a9808c5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 15 May 2026 10:42:42 -0700 Subject: [PATCH 01/16] chore(deps): bump mermaid to 11.15.0 for GHSA-ghcm-xqfw-q4vr (#4615) * chore(deps): bump mermaid to 11.15.0 for GHSA-ghcm-xqfw-q4vr * chore(deps): override transitive mermaid to 11.15.0 --- apps/sim/package.json | 3 ++- bun.lock | 31 +++++-------------------------- package.json | 3 ++- 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index d04d43b94d7..71521b166bb 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -149,7 +149,7 @@ "jwt-decode": "^4.0.0", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", - "mermaid": "11.14.0", + "mermaid": "11.15.0", "micromatch": "4.0.8", "monaco-editor": "0.55.1", "mongodb": "6.19.0", @@ -243,6 +243,7 @@ "overrides": { "next": "16.2.6", "@next/env": "16.2.6", + "mermaid": "11.15.0", "react-floater": { "react": "$react", "react-dom": "$react-dom" diff --git a/bun.lock b/bun.lock index 4724008c1c2..183834148bd 100644 --- a/bun.lock +++ b/bun.lock @@ -204,7 +204,7 @@ "jwt-decode": "^4.0.0", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", - "mermaid": "11.14.0", + "mermaid": "11.15.0", "micromatch": "4.0.8", "monaco-editor": "0.55.1", "mongodb": "6.19.0", @@ -477,6 +477,7 @@ "overrides": { "@next/env": "16.2.6", "drizzle-orm": "^0.45.2", + "mermaid": "11.15.0", "minimatch": "^10.2.5", "next": "16.2.6", "postgres": "^3.4.5", @@ -788,15 +789,7 @@ "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], - - "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="], - - "@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], - - "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@connectrpc/connect": ["@connectrpc/connect@2.0.0-rc.3", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }, "sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ=="], @@ -1006,7 +999,7 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], @@ -2058,10 +2051,6 @@ "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], - "chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.4.3", "", { "dependencies": { "lodash-es": "^4.18.1" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -2834,8 +2823,6 @@ "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], - "langium": ["langium@4.2.3", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.3", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng=="], - "langsmith": ["langsmith@0.3.87", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -3002,7 +2989,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], + "mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="], "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], @@ -3942,18 +3929,10 @@ "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], diff --git a/package.json b/package.json index bc7296a9c3e..9b1db911ac6 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "@next/env": "16.2.6", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", - "minimatch": "^10.2.5" + "minimatch": "^10.2.5", + "mermaid": "11.15.0" }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", From bad21cb23d64f4e030d000ec4e7e36d72a862c7f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Fri, 15 May 2026 10:48:21 -0700 Subject: [PATCH 02/16] improvement(agent, file-block): files in agent block, file block v4 (#4610) * File block v4 * Support files in agent block * Clean up attachments * Fix start.files * Fix * Fix --- .../integrations/data/icon-mapping.ts | 1 + .../integrations/data/integrations.json | 14 +- apps/sim/app/api/files/parse/route.test.ts | 34 ++ apps/sim/app/api/files/parse/route.ts | 24 +- apps/sim/app/api/tools/file/manage/route.ts | 149 ++++- .../app/api/workflows/[id]/execute/route.ts | 4 +- .../hooks/use-workflow-execution.ts | 3 + .../utils/workflow-execution-utils.ts | 1 + apps/sim/blocks/blocks.test.ts | 105 ++++ apps/sim/blocks/blocks/agent.ts | 28 +- apps/sim/blocks/blocks/file.ts | 289 +++++++++ apps/sim/blocks/registry.ts | 3 +- apps/sim/executor/execution/engine.ts | 13 - .../handlers/agent/agent-handler.test.ts | 72 +++ .../executor/handlers/agent/agent-handler.ts | 117 +++- .../executor/handlers/agent/memory.test.ts | 28 + apps/sim/executor/handlers/agent/memory.ts | 32 +- apps/sim/executor/handlers/agent/types.ts | 4 + apps/sim/executor/utils/start-block.test.ts | 36 ++ apps/sim/executor/utils/start-block.ts | 67 +- apps/sim/hooks/use-execution-stream.ts | 1 + .../sim/lib/api/contracts/storage-transfer.ts | 1 + apps/sim/lib/api/contracts/tools/file.ts | 12 + apps/sim/lib/api/contracts/workflows.ts | 1 + apps/sim/lib/uploads/utils/file-utils.ts | 4 +- .../uploads/utils/user-file-base64.server.ts | 8 +- apps/sim/providers/anthropic/core.ts | 5 +- apps/sim/providers/attachments.test.ts | 271 +++++++++ apps/sim/providers/attachments.ts | 571 ++++++++++++++++++ apps/sim/providers/azure-openai/index.ts | 3 + apps/sim/providers/bedrock/index.ts | 5 +- apps/sim/providers/cerebras/index.ts | 6 +- apps/sim/providers/deepseek/index.ts | 6 +- apps/sim/providers/fireworks/index.ts | 6 +- apps/sim/providers/gemini/core.ts | 2 +- apps/sim/providers/google/utils.test.ts | 38 ++ apps/sim/providers/google/utils.ts | 11 +- apps/sim/providers/groq/index.ts | 6 +- apps/sim/providers/mistral/index.ts | 6 +- apps/sim/providers/ollama/index.ts | 6 +- apps/sim/providers/openai/core.ts | 2 +- apps/sim/providers/openai/utils.test.ts | 40 ++ apps/sim/providers/openai/utils.ts | 26 +- apps/sim/providers/openrouter/index.ts | 6 +- apps/sim/providers/types.ts | 3 +- apps/sim/providers/vllm/index.ts | 6 +- apps/sim/providers/xai/index.ts | 6 +- apps/sim/tools/file/get.ts | 64 ++ apps/sim/tools/file/index.ts | 10 +- apps/sim/tools/file/parser.ts | 77 ++- apps/sim/tools/file/types.ts | 3 +- apps/sim/tools/registry.ts | 4 + 52 files changed, 2131 insertions(+), 109 deletions(-) create mode 100644 apps/sim/providers/attachments.test.ts create mode 100644 apps/sim/providers/attachments.ts create mode 100644 apps/sim/providers/openai/utils.test.ts diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index f80e850cebe..c4b0dfba957 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -258,6 +258,7 @@ export const blockTypeToIconMap: Record = { extend_v2: ExtendIcon, fathom: FathomIcon, file_v3: DocumentIcon, + file_v4: DocumentIcon, firecrawl: FirecrawlIcon, fireflies_v2: FirefliesIcon, gamma: GammaIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 3e6de1c0eef..0ba46960d75 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -4032,18 +4032,22 @@ "tags": ["meeting", "note-taking"] }, { - "type": "file_v3", + "type": "file_v4", "slug": "file", "name": "File", - "description": "Read and write workspace files", - "longDescription": "Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.", + "description": "Read, fetch, write, and append files", + "longDescription": "Read workspace files by picker or canonical ID, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.", "bgColor": "#40916C", "iconName": "DocumentIcon", "docsUrl": "https://docs.sim.ai/tools/file", "operations": [ { "name": "Read", - "description": "Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)" + "description": "Get a workspace file object from a selected file or canonical workspace file ID." + }, + { + "name": "Fetch", + "description": "Parse a file from a URL with optional custom headers for authenticated downloads." }, { "name": "Write", @@ -4054,7 +4058,7 @@ "description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file." } ], - "operationCount": 3, + "operationCount": 4, "triggers": [], "triggerCount": 0, "authType": "none", diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index 4283b6723b8..8c18422bae3 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -8,6 +8,7 @@ import { createMockRequest, hybridAuthMockFns, inputValidationMock, + inputValidationMockFns, permissionsMock, permissionsMockFns, storageServiceMock, @@ -310,6 +311,39 @@ describe('File Parse API Route', () => { expect(data.results).toHaveLength(2) }) + it('should pass custom headers when fetching external URLs', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('private file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + + const headers = { Authorization: 'Bearer xoxb-test-token' } + const req = createMockRequest('POST', { + filePath: 'https://files.slack.com/files-pri/T000-F000/download/report.txt', + headers, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://files.slack.com/files-pri/T000-F000/download/report.txt', + '203.0.113.10', + expect.objectContaining({ + timeout: 30000, + headers, + }) + ) + }) + it('should process execution file URLs with context query param', async () => { setupFileApiMocks({ cloudEnabled: true, diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index e52618c53fa..4bcd7d01914 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -110,7 +110,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { filePath, fileType, workspaceId, workflowId, executionId } = parsed.data.body + const { filePath, fileType, headers, workspaceId, workflowId, executionId } = parsed.data.body if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) { return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 }) @@ -128,6 +128,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, hasExecutionContext: !!executionContext, + hasHeaders: Boolean(headers && Object.keys(headers).length > 0), }) if (Array.isArray(filePath)) { @@ -146,7 +147,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileType, workspaceId, userId, - executionContext + executionContext, + headers ) if (result.metadata) { result.metadata.processingTime = Date.now() - startTime @@ -180,7 +182,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext) + const result = await parseFileSingle( + filePath, + fileType, + workspaceId, + userId, + executionContext, + headers + ) if (result.metadata) { result.metadata.processingTime = Date.now() - startTime @@ -225,7 +234,8 @@ async function parseFileSingle( fileType: string, workspaceId: string, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + headers?: Record ): Promise { logger.info('Parsing file:', filePath) @@ -251,7 +261,7 @@ async function parseFileSingle( } if (filePath.startsWith('http://') || filePath.startsWith('https://')) { - return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext) + return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext, headers) } if (isUsingCloudStorage()) { @@ -298,7 +308,8 @@ async function handleExternalUrl( fileType: string, workspaceId: string, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + headers?: Record ): Promise { try { logger.info('Fetching external URL:', url) @@ -382,6 +393,7 @@ async function handleExternalUrl( const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { timeout: DOWNLOAD_TIMEOUT_MS, + ...(headers && Object.keys(headers).length > 0 && { headers }), }) if (!response.ok) { throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 60082411473..65c129787b1 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -20,6 +20,100 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FileManageAPI') +const workspaceFileToUserFile = (file: Awaited>) => { + if (!file) return null + + return { + id: file.id, + name: file.name, + url: ensureAbsoluteUrl(file.path), + size: file.size, + type: file.type, + key: file.key, + context: 'workspace', + } +} + +const fileInputToUserFile = (fileInput: unknown) => { + if (!fileInput || typeof fileInput !== 'object' || Array.isArray(fileInput)) return null + + const record = fileInput as Record + const id = + typeof record.id === 'string' + ? record.id.trim() + : typeof record.fileId === 'string' + ? record.fileId.trim() + : '' + + // Objects with ids are resolved through workspace metadata. This fallback is for + // picker/upload values that only carry storage fields. + if (id) return null + + const key = typeof record.key === 'string' ? record.key.trim() : '' + const path = typeof record.path === 'string' ? record.path.trim() : '' + const url = typeof record.url === 'string' ? record.url.trim() : '' + const fileUrl = + url || path || (key ? `/api/files/serve/${encodeURIComponent(key)}?context=workspace` : '') + + if (!fileUrl && !key) return null + + return { + id: key || fileUrl, + name: + typeof record.name === 'string' && record.name.trim() ? record.name.trim() : 'workspace-file', + url: fileUrl ? ensureAbsoluteUrl(fileUrl) : '', + size: typeof record.size === 'number' ? record.size : 0, + type: + typeof record.type === 'string' && record.type.trim() + ? record.type.trim() + : 'application/octet-stream', + key, + context: 'workspace', + } +} + +const normalizeFileIdList = (value: unknown): string[] => { + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return [] + + try { + return normalizeFileIdList(JSON.parse(trimmed)) + } catch { + return [trimmed] + } + } + + if (!Array.isArray(value)) return [] + + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((id) => id.length > 0) +} + +const extractUserFilesFromInput = (fileInput: unknown) => { + const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : [] + return inputs + .map((input) => fileInputToUserFile(input)) + .filter((file): file is NonNullable> => Boolean(file)) +} + +const extractFileIdsFromInput = (fileInput: unknown): string[] => { + const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : [] + + return inputs + .flatMap((input) => { + if (typeof input === 'string') return normalizeFileIdList(input) + if (input && typeof input === 'object') { + const record = input as Record + if (typeof record.id === 'string') return normalizeFileIdList(record.id) + if (typeof record.fileId === 'string') return normalizeFileIdList(record.fileId) + } + return [] + }) + .filter((id) => id.length > 0) +} + export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request, { requireWorkflowId: false }) if (!auth.success) { @@ -76,15 +170,52 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: { - file: { - id: file.id, - name: file.name, - url: ensureAbsoluteUrl(file.path), - size: file.size, - type: file.type, - key: file.key, - context: 'workspace', - }, + file: workspaceFileToUserFile(file), + }, + }) + } + + case 'read': { + const { fileId, fileInput } = body + const selectedFileIds = Array.isArray(fileId) + ? fileId.map((id) => id.trim()).filter(Boolean) + : fileId + ? normalizeFileIdList(fileId) + : extractFileIdsFromInput(fileInput) + const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + + if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const files = await Promise.all( + selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) + ) + const missingFileId = selectedFileIds.find((_, index) => !files[index]) + if (missingFileId) { + return NextResponse.json( + { success: false, error: `File not found: "${missingFileId}"` }, + { status: 404 } + ) + } + + const userFiles = files + .map((file) => workspaceFileToUserFile(file)) + .filter((file): file is NonNullable> => + Boolean(file) + ) + .concat(selectedInputFiles) + + logger.info('Files retrieved', { + count: userFiles.length, + fileIds: userFiles.map((file) => file.id), + }) + + return NextResponse.json({ + success: true, + data: { + file: userFiles[0], + files: userFiles, }, }) } diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 9d042cea756..b2d80e8ddb2 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -359,6 +359,7 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, + executionId: requestedExecutionId, triggerBlockId: parsedTriggerBlockId, startBlockId, stopAfterBlockId, @@ -508,7 +509,8 @@ async function handleExecutePost( ) } - const executionId = generateId() + const executionId = + isClientSession && requestedExecutionId ? requestedExecutionId : generateId() reqLogger = reqLogger.withMetadata({ userId, executionId }) reqLogger.info('Starting server-side execution', { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 845801846d2..604d0edd9c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -524,6 +524,7 @@ export function useWorkflowExecution() { size: fileData.file.size, type: fileData.file.type, key: result.key, + context: 'execution', }) } catch (uploadError) { if ( @@ -565,6 +566,7 @@ export function useWorkflowExecution() { size: r.size, type: r.type, key: r.key, + context: r.context || 'execution', uploadedAt: r.uploadedAt, expiresAt: r.expiresAt, }) @@ -1126,6 +1128,7 @@ export function useWorkflowExecution() { await executionStream.execute({ workflowId: activeWorkflowId, input: finalWorkflowInput, + executionId, startBlockId, selectedOutputs, triggerType: overrideTriggerType || 'manual', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 4b9e726d089..6e26e92fe81 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -903,6 +903,7 @@ export async function executeWorkflowWithFullLogging( triggerType: options.overrideTriggerType || 'manual', useDraftState: options.useDraftState ?? true, isClientSession: true, + ...(options.executionId ? { executionId: options.executionId } : {}), ...(options.triggerBlockId ? { triggerBlockId: options.triggerBlockId } : {}), ...(options.stopAfterBlockId ? { stopAfterBlockId: options.stopAfterBlockId } : {}), ...(options.runFromBlock diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 9659863a837..8906f962ef3 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -94,6 +94,111 @@ describe.concurrent('Blocks Module', () => { }) }) + describe('File block', () => { + it('should keep v3 read and get routed to the legacy tools', () => { + const block = getBlock('file_v3') + + expect(block).toBeDefined() + expect(block?.hideFromToolbar).toBe(true) + expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([ + 'file_parser_v3', + 'file_get', + 'file_write', + 'file_append', + ]) + expect(block?.tools.config?.tool({ operation: 'file_parser_v3' })).toBe('file_parser_v3') + expect(block?.tools.config?.tool({ operation: 'file_get' })).toBe('file_get') + }) + + it('should expose v4 with read and fetch routed to the expected tools', () => { + const block = getBlock('file_v4') + + expect(block).toBeDefined() + expect(block?.hideFromToolbar).toBe(false) + expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([ + 'file_read', + 'file_fetch', + 'file_write', + 'file_append', + ]) + expect(block?.subBlocks.find((subBlock) => subBlock.id === 'readFile')?.multiple).toBe(true) + expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read') + expect(block?.tools.config?.tool({ operation: 'file_fetch' })).toBe('file_fetch') + expect( + block?.tools.config?.params?.({ + operation: 'file_read', + readFileInput: '["file-1","file-2"]', + _context: { workspaceId: 'workspace-1' }, + }) + ).toEqual({ + fileId: ['file-1', 'file-2'], + workspaceId: 'workspace-1', + }) + expect( + block?.tools.config?.params?.({ + operation: 'file_read', + readFileInput: [ + { + key: 'workspace/workspace-1/example.md', + name: 'example.md', + path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', + size: 123, + type: 'text/markdown', + }, + ], + _context: { workspaceId: 'workspace-1' }, + }) + ).toEqual({ + fileInput: [ + { + key: 'workspace/workspace-1/example.md', + name: 'example.md', + path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', + size: 123, + type: 'text/markdown', + }, + ], + workspaceId: 'workspace-1', + }) + }) + }) + + describe('Agent block', () => { + it('should expose canonical file attachments and normalize file params', () => { + const block = getBlock('agent') + + expect(block).toBeDefined() + const uploadSubBlock = block?.subBlocks.find((subBlock) => subBlock.id === 'attachmentFiles') + const advancedSubBlock = block?.subBlocks.find((subBlock) => subBlock.id === 'files') + + expect(uploadSubBlock?.type).toBe('file-upload') + expect(uploadSubBlock?.canonicalParamId).toBe('files') + expect(uploadSubBlock?.multiple).toBe(true) + expect(advancedSubBlock?.canonicalParamId).toBe('files') + expect(block?.inputs.files).toEqual({ + type: 'array', + description: 'Files to include with the latest user message', + }) + + expect( + block?.tools.config?.params?.({ + model: 'gpt-4o', + files: + '[{"id":"file-1","key":"workspace/ws-1/example.png","name":"example.png","url":"/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace","size":123,"type":"image/png"}]', + }) + ).toMatchObject({ + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + type: 'image/png', + }, + ], + }) + }) + }) + describe('getBlocksByCategory', () => { it('should return blocks in the "blocks" category', () => { const blocks = getBlocksByCategory('blocks') diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 3a8e704859d..652270de636 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -5,6 +5,7 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import { getModelOptions, getProviderCredentialSubBlocks, + normalizeFileInput, RESPONSE_FORMAT_WAND_CONFIG, } from '@/blocks/utils' import { @@ -133,6 +134,25 @@ Return ONLY the JSON array.`, defaultValue: 'claude-sonnet-4-6', options: getModelOptions, }, + { + id: 'attachmentFiles', + title: 'Files', + type: 'file-upload', + canonicalParamId: 'files', + placeholder: 'Upload files for the agent', + multiple: true, + mode: 'basic', + required: false, + }, + { + id: 'files', + title: 'Files', + type: 'short-input', + canonicalParamId: 'files', + placeholder: 'Reference files from previous blocks', + mode: 'advanced', + required: false, + }, { id: 'reasoningEffort', title: 'Reasoning Effort', @@ -472,6 +492,9 @@ Return ONLY the JSON array.`, return tool }, params: (params: Record) => { + const normalizedFiles = normalizeFileInput(params.files) + const baseParams = normalizedFiles ? { ...params, files: normalizedFiles } : params + // If tools array is provided, handle tool usage control if (params.tools && Array.isArray(params.tools)) { // Transform tools to include usageControl @@ -506,9 +529,9 @@ Return ONLY the JSON array.`, logger.info('Filtered out tools set to none', { tools: filteredOutTools.join(', ') }) } - return { ...params, tools: transformedTools } + return { ...baseParams, tools: transformedTools } } - return params + return baseParams }, }, }, @@ -518,6 +541,7 @@ Return ONLY the JSON array.`, description: 'Array of message objects with role and content: [{ role: "system", content: "..." }, { role: "user", content: "..." }]', }, + files: { type: 'array', description: 'Files to include with the latest user message' }, memoryType: { type: 'string', description: diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index efefb329824..43904e9816f 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -259,6 +259,7 @@ export const FileV3Block: BlockConfig = { tags: ['document-processing'], bgColor: '#40916C', icon: DocumentIcon, + hideFromToolbar: true, subBlocks: [ { id: 'operation', @@ -514,3 +515,291 @@ export const FileV3Block: BlockConfig = { }, }, } + +const parseReadFileIds = (input: unknown): string | string[] | null => { + let value = input + + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + + try { + value = JSON.parse(trimmed) + } catch { + return trimmed + } + } + + if (Array.isArray(value)) { + const fileIds = value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0) + + if (fileIds.length === 0) return null + return fileIds.length === 1 ? fileIds[0] : fileIds + } + + return null +} + +export const FileV4Block: BlockConfig = { + ...FileV3Block, + type: 'file_v4', + name: 'File', + description: 'Read, fetch, write, and append files', + longDescription: + 'Read workspace files by picker or canonical ID, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.', + hideFromToolbar: false, + bestPractices: ` + - Use Read when you need an existing workspace file object by picker selection or canonical file ID. + - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. + `, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown' as SubBlockType, + options: [ + { label: 'Read', id: 'file_read' }, + { label: 'Fetch', id: 'file_fetch' }, + { label: 'Write', id: 'file_write' }, + { label: 'Append', id: 'file_append' }, + ], + value: () => 'file_read', + }, + { + id: 'readFile', + title: 'File', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'readFileInput', + acceptedTypes: '*', + placeholder: 'Select workspace files', + multiple: true, + mode: 'basic', + condition: { field: 'operation', value: 'file_read' }, + required: { field: 'operation', value: 'file_read' }, + }, + { + id: 'readFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'readFileInput', + placeholder: 'Workspace file ID or JSON array of IDs', + mode: 'advanced', + condition: { field: 'operation', value: 'file_read' }, + required: { field: 'operation', value: 'file_read' }, + }, + { + id: 'fileUrl', + title: 'File URL', + type: 'short-input' as SubBlockType, + placeholder: 'https://example.com/document.pdf', + condition: { field: 'operation', value: 'file_fetch' }, + required: { field: 'operation', value: 'file_fetch' }, + }, + { + id: 'headers', + title: 'Headers', + type: 'table' as SubBlockType, + columns: ['Key', 'Value'], + description: + 'Custom headers for fetching the file URL, such as Authorization: Bearer .', + condition: { field: 'operation', value: 'file_fetch' }, + }, + { + id: 'fileName', + title: 'File Name', + type: 'short-input' as SubBlockType, + placeholder: 'File name (e.g., data.csv)', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'File content to write...', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'contentType', + title: 'Content Type', + type: 'short-input' as SubBlockType, + placeholder: 'text/plain (auto-detected from extension)', + condition: { field: 'operation', value: 'file_write' }, + mode: 'advanced', + }, + { + id: 'appendFile', + title: 'File', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'appendFileInput', + acceptedTypes: '.txt,.md,.json,.csv,.xml,.html,.htm,.yaml,.yml,.log,.rtf', + placeholder: 'Select or upload a workspace file', + mode: 'basic', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + }, + { + id: 'appendFileName', + title: 'File', + type: 'short-input' as SubBlockType, + canonicalParamId: 'appendFileInput', + placeholder: 'File name (e.g., notes.md)', + mode: 'advanced', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + }, + { + id: 'appendContent', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'Content to append...', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + }, + ], + tools: { + access: ['file_fetch', 'file_read', 'file_write', 'file_append'], + config: { + tool: (params) => { + const operation = params.operation || 'file_read' + if (operation === 'file_read') return 'file_read' + if (operation === 'file_fetch') return 'file_fetch' + return operation + }, + params: (params) => { + const operation = params.operation || 'file_read' + + if (operation === 'file_write') { + return { + fileName: params.fileName, + content: params.content, + contentType: params.contentType, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_append') { + const appendInput = params.appendFileInput + if (!appendInput) { + throw new Error('File is required for append') + } + + let fileName: string + if (typeof appendInput === 'string') { + fileName = appendInput.trim() + } else { + const normalized = normalizeFileInput(appendInput, { single: true }) + const file = normalized as Record | null + fileName = (file?.name as string) ?? '' + } + + if (!fileName) { + throw new Error('Could not determine file name') + } + + return { + fileName, + content: params.appendContent, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_read') { + const readInput = params.readFileInput + if (!readInput) { + throw new Error('File is required for read') + } + + const fileIds = parseReadFileIds(readInput) + if (fileIds) { + return { + fileId: fileIds, + workspaceId: params._context?.workspaceId, + } + } + + const normalized = normalizeFileInput(readInput) + if (!normalized || normalized.length === 0) { + throw new Error('File is required for read') + } + + return { + fileInput: normalized, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_fetch') { + const fileUrl = typeof params.fileUrl === 'string' ? params.fileUrl.trim() : '' + if (!fileUrl) { + logger.error('No file URL provided') + throw new Error('File URL is required') + } + + return { + filePath: fileUrl, + fileType: params.fileType || 'auto', + headers: params.headers, + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + } + } + + logger.error(`Invalid file operation: ${operation}`) + throw new Error('Invalid file operation') + }, + }, + }, + inputs: { + operation: { + type: 'string', + description: 'Operation to perform (read, fetch, write, or append)', + }, + readFileInput: { + type: 'json', + description: 'Selected workspace file or canonical file ID for read', + }, + fileUrl: { type: 'string', description: 'External file URL for fetch' }, + headers: { type: 'json', description: 'Request headers for fetch' }, + fileType: { type: 'string', description: 'File type for fetch' }, + fileName: { type: 'string', description: 'Name for a new file (write)' }, + content: { type: 'string', description: 'File content to write' }, + contentType: { type: 'string', description: 'MIME content type for write' }, + appendFileInput: { type: 'json', description: 'File to append to' }, + appendContent: { type: 'string', description: 'Content to append to file' }, + }, + outputs: { + file: { + type: 'file', + description: 'First workspace file object (read)', + }, + files: { + type: 'file[]', + description: 'Workspace file objects (read) or fetched file objects (fetch)', + }, + combinedContent: { + type: 'string', + description: 'All fetched file contents merged into a single text string (fetch)', + }, + id: { + type: 'string', + description: 'File ID (write)', + }, + name: { + type: 'string', + description: 'File name (write)', + }, + size: { + type: 'number', + description: 'File size in bytes (write)', + }, + url: { + type: 'string', + description: 'URL to access the file (write)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index d458289879a..cd6144ff5f7 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -55,7 +55,7 @@ import { EvernoteBlock } from '@/blocks/blocks/evernote' import { ExaBlock } from '@/blocks/blocks/exa' import { ExtendBlock, ExtendV2Block } from '@/blocks/blocks/extend' import { FathomBlock } from '@/blocks/blocks/fathom' -import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file' +import { FileBlock, FileV2Block, FileV3Block, FileV4Block } from '@/blocks/blocks/file' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies' import { FunctionBlock } from '@/blocks/blocks/function' @@ -299,6 +299,7 @@ export const registry: Record = { file: FileBlock, file_v2: FileV2Block, file_v3: FileV3Block, + file_v4: FileV4Block, firecrawl: FirecrawlBlock, fireflies: FirefliesBlock, fireflies_v2: FirefliesV2Block, diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 82497858911..45880bc44d2 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -449,19 +449,6 @@ export class ExecutionEngine { const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false) - this.execLogger.info('Processing outgoing edges', { - nodeId, - outgoingEdgesCount: node.outgoingEdges.size, - outgoingEdges: Array.from(node.outgoingEdges.entries()).map(([id, e]) => ({ - id, - target: e.target, - sourceHandle: e.sourceHandle, - })), - output, - readyNodesCount: readyNodes.length, - readyNodes, - }) - this.addMultipleToQueue(readyNodes) if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) { diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 169cb4f52f9..2d7a89c0a87 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -264,6 +264,78 @@ describe('AgentBlockHandler', () => { expect(result).toEqual(expectedOutput) }) + it('should attach files to the last user message only', async () => { + const inputs = { + model: 'gpt-4o', + messages: [ + { role: 'system' as const, content: 'You are helpful.' }, + { role: 'user' as const, content: 'Earlier question' }, + { role: 'assistant' as const, content: 'Earlier answer' }, + { role: 'user' as const, content: 'Analyze this file' }, + ], + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', + size: 128, + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ], + apiKey: 'test-api-key', + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, inputs) + + const requestBody = mockExecuteProviderRequest.mock.calls[0][1] + expect(requestBody.messages[1]).toMatchObject({ + role: 'user', + content: 'Earlier question', + }) + expect(requestBody.messages[1].files).toBeUndefined() + expect(requestBody.messages[3]).toMatchObject({ + role: 'user', + content: 'Analyze this file', + files: [ + { + id: 'file-1', + name: 'example.png', + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ], + }) + }) + + it('should reject files for providers without attachment support', async () => { + const inputs = { + model: 'deepseek-chat', + messages: [{ role: 'user' as const, content: 'Analyze this file' }], + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', + size: 128, + type: 'image/png', + base64: 'aW1hZ2U=', + }, + ], + apiKey: 'test-api-key', + } + + mockGetProviderFromModel.mockReturnValue('deepseek') + + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( + 'File attachments are not supported for provider "deepseek"' + ) + }) + it('should preserve usageControl for custom tools and filter out "none"', async () => { const inputs = { model: 'gpt-4o', diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index e09b75387f7..b774dcfcf28 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -6,9 +6,12 @@ import { sleep } from '@sim/utils/helpers' import { and, eq, inArray, isNull } from 'drizzle-orm' import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records' import { createMcpToolId } from '@/lib/mcp/utils' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { getAllBlocks } from '@/blocks' import type { BlockOutput } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import { validateBlockType, validateCustomToolsAllowed, @@ -36,6 +39,7 @@ import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' +import { getProviderAttachmentMaxBytes, supportsFileAttachments } from '@/providers/attachments' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { filterSchemaForLLM, type ToolSchema } from '@/tools/params' @@ -87,12 +91,22 @@ export class AgentBlockHandler implements BlockHandler { const streamingConfig = this.getStreamingConfig(ctx, block) const messages = await this.buildMessages(ctx, filteredInputs, skillMetadata) + const messagesWithInputFiles = this.attachFilesToLastUserMessage( + ctx, + messages, + filteredInputs.files + ) + const messagesWithFiles = await this.hydrateMessageFilesForProvider( + ctx, + messagesWithInputFiles, + providerId + ) const providerRequest = this.buildProviderRequest({ ctx, providerId, model, - messages, + messages: messagesWithFiles, inputs: filteredInputs, formattedTools, responseFormat, @@ -672,6 +686,107 @@ export class AgentBlockHandler implements BlockHandler { return messages.length > 0 ? messages : undefined } + private attachFilesToLastUserMessage( + ctx: ExecutionContext, + messages: Message[] | undefined, + filesInput: unknown + ): Message[] | undefined { + const normalizedFiles = normalizeFileInput(filesInput) + if (!normalizedFiles || normalizedFiles.length === 0) { + return messages + } + + if (!messages || messages.length === 0) { + throw new Error('Files require at least one user message in the agent prompt') + } + + let lastUserMessageIndex = -1 + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index].role === 'user') { + lastUserMessageIndex = index + break + } + } + if (lastUserMessageIndex === -1) { + throw new Error('Files require at least one user message in the agent prompt') + } + + const requestId = ctx.executionId || ctx.workflowId || 'agent-files' + const userFiles = processFilesToUserFiles(normalizedFiles as RawFileInput[], requestId, logger) + if (userFiles.length === 0) { + throw new Error('Files must include at least one valid file object') + } + + const lastUserMessage = messages[lastUserMessageIndex] + const nextMessages = [...messages] + nextMessages[lastUserMessageIndex] = { + ...lastUserMessage, + files: [...(lastUserMessage.files ?? []), ...userFiles], + } + + return nextMessages + } + + private async hydrateMessageFilesForProvider( + ctx: ExecutionContext, + messages: Message[] | undefined, + providerId: string + ): Promise { + if (!messages?.some((message) => message.files?.length)) { + return messages + } + + if (!supportsFileAttachments(providerId)) { + throw new Error(`File attachments are not supported for provider "${providerId}"`) + } + + const requestId = ctx.executionId || ctx.workflowId || 'agent-files' + const nextMessages = [...messages] + + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const message = messages[messageIndex] + const normalizedFiles = normalizeFileInput(message.files) + if (!normalizedFiles || normalizedFiles.length === 0) { + continue + } + + const userFiles = processFilesToUserFiles( + normalizedFiles as RawFileInput[], + requestId, + logger + ) + if (userFiles.length === 0) { + throw new Error('Files must include at least one valid file object') + } + + const hydratedFiles = await hydrateUserFilesWithBase64(userFiles, { + requestId, + workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, + executionId: ctx.executionId, + largeValueExecutionIds: ctx.largeValueExecutionIds, + allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope, + userId: ctx.userId, + logger, + maxBytes: getProviderAttachmentMaxBytes(providerId), + }) + + const missingFile = hydratedFiles.find((file) => !file.base64) + if (missingFile) { + throw new Error( + `File "${missingFile.name}" could not be read for provider "${providerId}". Make sure the file is still accessible and under the provider attachment size limit.` + ) + } + + nextMessages[messageIndex] = { + ...message, + files: hydratedFiles, + } + } + + return nextMessages + } + private extractValidMessages(messages?: Message[]): Message[] { if (!messages || !Array.isArray(messages)) return [] diff --git a/apps/sim/executor/handlers/agent/memory.test.ts b/apps/sim/executor/handlers/agent/memory.test.ts index 316b2a0e731..b2182921ce6 100644 --- a/apps/sim/executor/handlers/agent/memory.test.ts +++ b/apps/sim/executor/handlers/agent/memory.test.ts @@ -178,6 +178,34 @@ describe('Memory', () => { }) }) + describe('sanitizeMessageForStorage', () => { + it('should strip file payloads and provider-only fields before memory persistence', () => { + const message: Message = { + role: 'user', + content: 'Analyze this file', + executionId: 'exec-1', + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', + size: 128, + type: 'image/png', + base64: 'iVBORw0KGgo=', + }, + ], + tool_calls: [{ id: 'call-1' }], + } + + expect((memoryService as any).sanitizeMessageForStorage(message)).toEqual({ + role: 'user', + content: 'Analyze this file', + executionId: 'exec-1', + }) + }) + }) + describe('Token-based vs Message-based comparison', () => { it('should produce different results for same limit concept', () => { const messages: Message[] = [ diff --git a/apps/sim/executor/handlers/agent/memory.ts b/apps/sim/executor/handlers/agent/memory.ts index fcba3628226..d9e693210d8 100644 --- a/apps/sim/executor/handlers/agent/memory.ts +++ b/apps/sim/executor/handlers/agent/memory.ts @@ -122,6 +122,14 @@ export class Memory { return messages.slice(-limit) } + private sanitizeMessageForStorage(message: Message): Message { + return { + role: message.role, + content: message.content, + ...(message.executionId && { executionId: message.executionId }), + } + } + private applyTokenWindow(messages: Message[], maxTokens: number, model?: string): Message[] { const result: Message[] = [] let tokenCount = 0 @@ -177,9 +185,17 @@ export class Memory { const data = result[0].data if (!Array.isArray(data)) return [] - return data.filter( - (msg): msg is Message => msg && typeof msg === 'object' && 'role' in msg && 'content' in msg - ) + return data + .filter( + (msg): msg is Message => + msg && + typeof msg === 'object' && + 'role' in msg && + 'content' in msg && + ['system', 'user', 'assistant'].includes(msg.role) && + typeof msg.content === 'string' + ) + .map((msg) => this.sanitizeMessageForStorage(msg)) } private async seedMemoryRecord( @@ -189,13 +205,15 @@ export class Memory { ): Promise { const now = new Date() + const sanitizedMessages = messages.map((message) => this.sanitizeMessageForStorage(message)) + await db .insert(memory) .values({ id: generateId(), workspaceId, key, - data: messages, + data: sanitizedMessages, createdAt: now, updatedAt: now, }) @@ -205,20 +223,22 @@ export class Memory { private async appendMessage(workspaceId: string, key: string, message: Message): Promise { const now = new Date() + const sanitizedMessage = this.sanitizeMessageForStorage(message) + await db .insert(memory) .values({ id: generateId(), workspaceId, key, - data: [message], + data: [sanitizedMessage], createdAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: [memory.workspaceId, memory.key], set: { - data: sql`${memory.data} || ${JSON.stringify([message])}::jsonb`, + data: sql`${memory.data} || ${JSON.stringify([sanitizedMessage])}::jsonb`, updatedAt: now, }, }) diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 7f4a185bacc..39329739a43 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -1,3 +1,5 @@ +import type { UserFile } from '@/executor/types' + export interface SkillInput { skillId: string name?: string @@ -37,6 +39,7 @@ export interface AgentInputs { reasoningEffort?: string verbosity?: string thinkingLevel?: string + files?: unknown } /** @@ -66,6 +69,7 @@ export interface ToolInput { export interface Message { role: 'system' | 'user' | 'assistant' content: string + files?: UserFile[] executionId?: string function_call?: any tool_calls?: any[] diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index f50c5a58da5..1e8cadf9c48 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -119,6 +119,42 @@ describe('start-block utilities', () => { expect(output.files).toEqual(files) }) + it.concurrent('buildStartBlockOutput normalizes Start files from internal serve URLs', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'screenshot.png', + url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution', + size: 243289, + type: 'image/png', + }, + ], + }, + }) + + expect(output.files).toEqual([ + { + id: 'file_1', + name: 'screenshot.png', + url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution', + size: 243289, + type: 'image/png', + key: 'execution/workspace-id/workflow-id/execution-id/screenshot.png', + context: 'execution', + }, + ]) + }) + it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => { const block = createBlock('start_trigger', 'start', { subBlocks: { diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index 8d0a4afcb0e..ddd75ce221c 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -1,4 +1,8 @@ -import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' +import { + inferContextFromKey, + isInternalFileUrl, + parseInternalFileUrl, +} from '@/lib/uploads/utils/file-utils' import { classifyStartBlockType, resolveStartCandidates, @@ -323,13 +327,68 @@ function getRawInputCandidate(workflowInput: unknown): unknown { return workflowInput } +function normalizeStartFile(file: unknown): UserFile | null { + if (!isPlainObject(file)) { + return null + } + + const id = typeof file.id === 'string' ? file.id : '' + const name = typeof file.name === 'string' ? file.name : '' + const url = + typeof file.url === 'string' ? file.url : typeof file.path === 'string' ? file.path : '' + const size = typeof file.size === 'number' ? file.size : Number.NaN + const type = typeof file.type === 'string' ? file.type : '' + const explicitKey = typeof file.key === 'string' ? file.key : '' + + let key = explicitKey + let context = typeof file.context === 'string' ? file.context : undefined + + if (!key && url && isInternalFileUrl(url)) { + try { + const parsed = parseInternalFileUrl(url) + key = parsed.key + context = context || parsed.context + } catch { + return null + } + } + + if (!context && key) { + try { + context = inferContextFromKey(key) + } catch { + // Older file outputs may have opaque keys; keep the file shape intact. + } + } + + if (!id || !name || !url || !Number.isFinite(size) || !type || !key) { + return null + } + + return { + id, + name, + url, + size, + type, + key, + ...(context && { context }), + ...(typeof file.base64 === 'string' && { base64: file.base64 }), + } +} + function getFilesFromWorkflowInput(workflowInput: unknown): UserFile[] | undefined { if (!isPlainObject(workflowInput)) { return undefined } const files = workflowInput.files - if (Array.isArray(files) && files.every(isUserFileWithMetadata)) { - return files + if (!Array.isArray(files)) { + return undefined + } + + const normalizedFiles = files.map(normalizeStartFile) + if (normalizedFiles.every((file): file is UserFile => Boolean(file))) { + return normalizedFiles } return undefined } @@ -341,6 +400,8 @@ function mergeFilesIntoOutput( const files = getFilesFromWorkflowInput(workflowInput) if (files) { output.files = files + } else if (isPlainObject(workflowInput) && Object.hasOwn(workflowInput, 'files')) { + output.files = undefined } return output } diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index b45a8550ba6..c07fd2a3bff 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -197,6 +197,7 @@ export interface ExecuteStreamOptions { envVarValues?: Record workflowVariables?: Record selectedOutputs?: string[] + executionId?: string startBlockId?: string triggerType?: string useDraftState?: boolean diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 7cdc7edea56..7142c65ea90 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -302,6 +302,7 @@ export const fileParseBodySchema = z .object({ filePath: z.union([z.string(), z.array(z.string())]).optional(), fileType: z.string().optional().default(''), + headers: z.record(z.string(), z.string()).optional(), workspaceId: z.string().optional().default(''), workflowId: z.string().optional(), executionId: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 7120d38d15a..c91142a800a 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -33,10 +33,22 @@ export const fileManageGetBodySchema = z message: 'Either fileId or fileInput is required for get operation', }) +export const fileManageReadBodySchema = z + .object({ + operation: z.literal('read'), + workspaceId: z.string().min(1).optional(), + fileId: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(), + fileInput: z.any().optional(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for read operation', + }) + export const fileManageBodySchema = z.union([ fileManageWriteBodySchema, fileManageAppendBodySchema, fileManageGetBodySchema, + fileManageReadBodySchema, ]) export const fileManageContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 46e5095c933..e439ff78cdf 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -336,6 +336,7 @@ export const executeWorkflowBodySchema = z.object({ includeFileBase64: z.boolean().optional().default(true), base64MaxBytes: z.number().int().positive().optional(), workflowStateOverride: workflowStateSchema.optional(), + executionId: z.string().optional(), triggerBlockId: z.string().optional(), startBlockId: z.string().optional(), stopAfterBlockId: z.string().optional(), diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index cf49299f5d5..cad992fa94b 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -172,7 +172,7 @@ export function createFileContentFromBase64(base64: string, mimeType: string): M return null } - if (contentType === 'image' && !CLAUDE_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase())) { + if (contentType === 'image' && !MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase())) { return null } @@ -186,7 +186,7 @@ export function createFileContentFromBase64(base64: string, mimeType: string): M } } -const CLAUDE_SUPPORTED_IMAGE_MIME_TYPES = new Set([ +export const MODEL_SUPPORTED_IMAGE_MIME_TYPES = new Set([ 'image/jpeg', 'image/jpg', 'image/png', diff --git a/apps/sim/lib/uploads/utils/user-file-base64.server.ts b/apps/sim/lib/uploads/utils/user-file-base64.server.ts index 8d5e7b048d1..3f666c16ae6 100644 --- a/apps/sim/lib/uploads/utils/user-file-base64.server.ts +++ b/apps/sim/lib/uploads/utils/user-file-base64.server.ts @@ -361,8 +361,7 @@ async function resolveBase64( options: Base64HydrationOptions, logger: Logger ): Promise { - const requestedMaxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES - const maxBytes = Math.min(requestedMaxBytes, DEFAULT_MAX_BASE64_BYTES) + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES if (file.base64) { const base64Bytes = Buffer.byteLength(file.base64, 'base64') @@ -440,10 +439,7 @@ async function hydrateUserFile( const cached = await state.cache.get(file) if (cached) { - const maxBytes = Math.min( - options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES, - DEFAULT_MAX_BASE64_BYTES - ) + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES if (Buffer.byteLength(cached, 'base64') > maxBytes) { return stripBase64(file) } diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index bda5c2f6f4a..ab080bcceec 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -9,6 +9,7 @@ import { checkForForcedToolUsage, createReadableStreamFromAnthropicStream, } from '@/providers/anthropic/utils' +import { buildAnthropicMessageContent } from '@/providers/attachments' import { getMaxOutputTokensForModel, getThinkingCapability, @@ -229,9 +230,11 @@ export async function executeAnthropicProviderRequest( ], }) } else { + const content = buildAnthropicMessageContent(msg.content, msg.files, config.providerId) messages.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', - content: msg.content ? [{ type: 'text', text: msg.content }] : [], + // double-cast-allowed: shared attachment builder returns Anthropic-compatible content blocks but avoids importing SDK-only union types + content: content as unknown as Anthropic.Messages.ContentBlockParam[], }) } }) diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts new file mode 100644 index 00000000000..813a7c54b53 --- /dev/null +++ b/apps/sim/providers/attachments.test.ts @@ -0,0 +1,271 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { UserFile } from '@/executor/types' +import { + buildAnthropicMessageContent, + buildBedrockMessageContent, + buildGeminiMessageParts, + buildOpenAIMessageContent, + buildOpenRouterMessageContent, + formatMessagesForProvider, + inferAttachmentMimeType, + prepareProviderAttachments, +} from '@/providers/attachments' + +const imageFile: UserFile = { + id: 'file-1', + name: 'example.png', + url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', + size: 128, + type: 'image/png', + key: 'workspace/ws-1/example.png', + base64: 'iVBORw0KGgo=', +} + +const pdfFile: UserFile = { + id: 'file-2', + name: 'example.pdf', + url: '/api/files/serve/workspace%2Fws-1%2Fexample.pdf?context=workspace', + size: 256, + type: 'application/pdf', + key: 'workspace/ws-1/example.pdf', + base64: 'cGRm', +} + +const markdownFile: UserFile = { + id: 'file-3', + name: 'notes.md', + url: '/api/files/serve/workspace%2Fws-1%2Fnotes.md?context=workspace', + size: 17, + type: 'text/markdown', + key: 'workspace/ws-1/notes.md', + base64: Buffer.from('# Notes\n\nHello').toString('base64'), +} + +describe('provider attachments', () => { + it('infers MIME type from filename when file type is generic', () => { + expect( + inferAttachmentMimeType({ + ...imageFile, + type: 'application/octet-stream', + }) + ).toBe('image/png') + }) + + it('formats OpenAI Responses content with text, image, and file parts', () => { + const content = buildOpenAIMessageContent( + 'Analyze these files', + [imageFile, pdfFile, markdownFile], + 'openai' + ) + + expect(content).toEqual([ + { type: 'input_text', text: 'Analyze these files' }, + { + type: 'input_image', + image_url: 'data:image/png;base64,iVBORw0KGgo=', + }, + { + type: 'input_file', + filename: 'example.pdf', + file_data: 'data:application/pdf;base64,cGRm', + }, + { + type: 'input_file', + filename: 'notes.md', + file_data: `data:text/markdown;base64,${markdownFile.base64}`, + }, + ]) + }) + + it('formats Anthropic content with image, PDF document, and text document blocks', () => { + const content = buildAnthropicMessageContent( + 'Analyze these files', + [imageFile, pdfFile, markdownFile], + 'anthropic' + ) + + expect(content).toEqual([ + { type: 'text', text: 'Analyze these files' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgo=', + }, + }, + { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: 'cGRm', + }, + title: 'example.pdf', + }, + { + type: 'document', + source: { + type: 'text', + media_type: 'text/plain', + data: '# Notes\n\nHello', + }, + title: 'notes.md', + }, + ]) + }) + + it('formats Gemini content with text and inline data parts', () => { + const parts = buildGeminiMessageParts('Analyze this file', [imageFile, markdownFile], 'google') + + expect(parts).toEqual([ + { text: 'Analyze this file' }, + { + inlineData: { + mimeType: 'image/png', + data: 'iVBORw0KGgo=', + }, + }, + { + inlineData: { + mimeType: 'text/plain', + data: markdownFile.base64, + }, + }, + ]) + }) + + it('formats Bedrock content with native document blocks', () => { + const parts = buildBedrockMessageContent('Analyze this file', [markdownFile], 'bedrock') + + expect(parts).toEqual([ + { text: 'Analyze this file' }, + { + document: { + format: 'md', + name: 'notes', + source: { + bytes: Buffer.from(markdownFile.base64, 'base64'), + }, + }, + }, + ]) + }) + + it('formats OpenRouter images and PDFs with native multimodal message parts', () => { + const content = buildOpenRouterMessageContent( + 'Analyze these files', + [imageFile, pdfFile], + 'openrouter' + ) + + expect(content).toEqual([ + { type: 'text', text: 'Analyze these files' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' }, + }, + { + type: 'file', + file: { + filename: 'example.pdf', + file_data: 'data:application/pdf;base64,cGRm', + }, + }, + ]) + }) + + it('formats image-only provider messages and strips file fields', () => { + const messages = formatMessagesForProvider( + [{ role: 'user', content: 'Analyze this image', files: [imageFile] }], + 'groq' + ) + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Analyze this image' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' }, + }, + ], + }, + ]) + }) + + it('fails fast for unsupported MIME types', () => { + expect(() => + prepareProviderAttachments( + [ + { + ...imageFile, + name: 'archive.zip', + type: 'application/zip', + }, + ], + 'openai' + ) + ).toThrow('application/zip') + }) + + it('sniffs image bytes and corrects a wrong declared image MIME type', () => { + const content = buildAnthropicMessageContent( + 'Analyze this image', + [ + { + ...imageFile, + name: 'wrong.ico', + type: 'image/x-icon', + }, + ], + 'anthropic' + ) + + expect(content[1]).toEqual({ + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgo=', + }, + }) + }) + + it('rejects image attachments when the bytes are not a supported image format', () => { + expect(() => + prepareProviderAttachments( + [ + { + ...imageFile, + name: 'not-an-image.png', + base64: Buffer.from('not an image').toString('base64'), + }, + ], + 'anthropic' + ) + ).toThrow('not a supported model image format') + }) + + it('rejects documents for image-only providers', () => { + expect(() => + formatMessagesForProvider( + [{ role: 'user', content: 'Analyze this file', files: [pdfFile] }], + 'groq' + ) + ).toThrow('Supported attachments: images') + }) + + it('rejects providers without file attachment support', () => { + expect(() => + formatMessagesForProvider( + [{ role: 'user', content: 'Analyze this file', files: [imageFile] }], + 'deepseek' + ) + ).toThrow('not supported') + }) +}) diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts new file mode 100644 index 00000000000..d165a1fc9e8 --- /dev/null +++ b/apps/sim/providers/attachments.ts @@ -0,0 +1,571 @@ +import { + getContentType, + getExtensionFromMimeType, + getFileExtension, + getMimeTypeFromExtension, + MIME_TYPE_MAPPING, + MODEL_SUPPORTED_IMAGE_MIME_TYPES, +} from '@/lib/uploads/utils/file-utils' +import type { UserFile } from '@/executor/types' +import type { ProviderId } from '@/providers/types' + +export type AttachmentProvider = + | 'openai' + | 'anthropic' + | 'google' + | 'bedrock' + | 'openrouter' + | 'mistral' + | 'groq' + | 'fireworks' + | 'ollama' + | 'vllm' + | 'xai' + | 'deepseek' + | 'cerebras' + +export interface PreparedProviderAttachment { + file: UserFile + filename: string + mimeType: string + providerMimeType: string + base64: string + dataUrl: string + text?: string + extension: string + contentType: 'image' | 'document' | 'audio' | 'video' +} + +type ProviderMessageInput = { + role: string + content?: string | null + files?: UserFile[] +} + +type ProviderFormattedMessage = { + role: string + content?: string | null | Array> + files?: UserFile[] + [key: string]: unknown +} + +const AGENT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 +const PDF_MIME_TYPE = 'application/pdf' + +const DOCUMENT_MIME_TYPES = new Set( + Object.entries(MIME_TYPE_MAPPING) + .filter(([, contentType]) => contentType === 'document') + .map(([mimeType]) => mimeType) +) + +const OPENAI_DOCUMENT_MIME_TYPES = new Set([...DOCUMENT_MIME_TYPES, 'application/x-yaml']) + +const GEMINI_INLINE_MIME_TYPES = new Set([...Object.keys(MIME_TYPE_MAPPING), 'application/x-yaml']) + +const BEDROCK_DOCUMENT_FORMATS = new Set([ + 'pdf', + 'csv', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'html', + 'txt', + 'md', +]) +const BEDROCK_IMAGE_FORMATS = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp']) +const BEDROCK_VIDEO_FORMATS = new Set(['mp4', 'mov', 'mkv', 'webm']) + +const IMAGE_ONLY_PROVIDERS = new Set([ + 'mistral', + 'groq', + 'fireworks', + 'ollama', + 'vllm', +]) + +const UNSUPPORTED_FILE_PROVIDERS = new Set(['xai', 'deepseek', 'cerebras']) + +const PROVIDER_SUPPORTED_LABELS: Record = { + openai: 'images and documents through the Responses API input_image/input_file parts', + anthropic: 'images, PDFs, and text documents through Claude content blocks', + google: 'images, audio, video, PDFs, and text documents through Gemini inlineData', + bedrock: 'Bedrock Converse image, document, and video content blocks', + openrouter: 'images and PDFs through OpenRouter multimodal message parts', + mistral: 'images through image_url message parts', + groq: 'images through image_url message parts on multimodal models', + fireworks: 'images through image_url message parts on vision models', + ollama: 'images through image_url message parts on vision models', + vllm: 'images through image_url message parts on multimodal models', + xai: 'no file attachments in the current chat-completions adapter', + deepseek: 'no file attachments in the current API adapter', + cerebras: 'no file attachments in the current API adapter', +} + +export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null { + if (providerId === 'openai' || providerId === 'azure-openai') return 'openai' + if (providerId === 'anthropic' || providerId === 'azure-anthropic') return 'anthropic' + if (providerId === 'google' || providerId === 'vertex') return 'google' + if (providerId === 'bedrock') return 'bedrock' + if (providerId === 'openrouter') return 'openrouter' + if (providerId === 'mistral') return 'mistral' + if (providerId === 'groq') return 'groq' + if (providerId === 'fireworks') return 'fireworks' + if (providerId === 'ollama') return 'ollama' + if (providerId === 'vllm') return 'vllm' + if (providerId === 'xai') return 'xai' + if (providerId === 'deepseek') return 'deepseek' + if (providerId === 'cerebras') return 'cerebras' + return null +} + +export function getProviderAttachmentMaxBytes(_providerId: ProviderId | string): number { + return AGENT_ATTACHMENT_MAX_BYTES +} + +export function supportsFileAttachments(providerId: ProviderId | string): boolean { + const provider = getAttachmentProvider(providerId) + return Boolean(provider && !UNSUPPORTED_FILE_PROVIDERS.has(provider)) +} + +export function inferAttachmentMimeType(file: UserFile): string { + const explicitType = file.type?.trim().toLowerCase() + if (explicitType && explicitType !== 'application/octet-stream') { + return explicitType + } + + const inferred = getMimeTypeFromExtension(getFileExtension(file.name)) + return inferred.toLowerCase() +} + +function isTextDocumentMimeType(mimeType: string): boolean { + return ( + mimeType.startsWith('text/') || + mimeType === 'application/json' || + mimeType === 'application/xml' || + mimeType === 'application/x-yaml' + ) +} + +function isImageMimeType(mimeType: string): boolean { + return MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType) +} + +function isOpenAIDocumentMimeType(mimeType: string): boolean { + return OPENAI_DOCUMENT_MIME_TYPES.has(mimeType) || isTextDocumentMimeType(mimeType) +} + +function getAttachmentContentType( + mimeType: string +): PreparedProviderAttachment['contentType'] | null { + return getContentType(mimeType) || (isTextDocumentMimeType(mimeType) ? 'document' : null) +} + +function sniffImageMimeType(base64: string): string { + let bytes: Buffer + try { + bytes = Buffer.from(base64, 'base64') + } catch { + return '' + } + + if ( + bytes.length >= 8 && + bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return 'image/png' + } + + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + + if ( + bytes.length >= 6 && + (bytes.subarray(0, 6).equals(Buffer.from('GIF87a')) || + bytes.subarray(0, 6).equals(Buffer.from('GIF89a'))) + ) { + return 'image/gif' + } + + if ( + bytes.length >= 12 && + bytes.subarray(0, 4).equals(Buffer.from('RIFF')) && + bytes.subarray(8, 12).equals(Buffer.from('WEBP')) + ) { + return 'image/webp' + } + + return '' +} + +function getAttachmentExtension(file: UserFile, mimeType: string): string { + if (mimeType === 'text/markdown') return 'md' + return getExtensionFromMimeType(mimeType) || getFileExtension(file.name) +} + +function normalizeProviderMimeType(mimeType: string, provider: AttachmentProvider): string { + if ((provider === 'anthropic' || provider === 'google') && isTextDocumentMimeType(mimeType)) { + return 'text/plain' + } + return mimeType +} + +function decodeBase64Text(base64: string, filename: string): string { + try { + return Buffer.from(base64, 'base64').toString('utf8') + } catch { + throw new Error(`File "${filename}" could not be decoded as UTF-8 text`) + } +} + +function toDataUrl(mimeType: string, base64: string): string { + return `data:${mimeType};base64,${base64}` +} + +function getProviderSupportedLabel(provider: AttachmentProvider): string { + return PROVIDER_SUPPORTED_LABELS[provider] +} + +function validateProviderSupport( + attachment: Omit, + provider: AttachmentProvider, + providerId: ProviderId | string +) { + const { filename, mimeType, contentType, extension } = attachment + const supportedLabel = getProviderSupportedLabel(provider) + + const supported = + provider === 'openai' + ? isImageMimeType(mimeType) || isOpenAIDocumentMimeType(mimeType) + : provider === 'anthropic' + ? isImageMimeType(mimeType) || + mimeType === PDF_MIME_TYPE || + isTextDocumentMimeType(mimeType) + : provider === 'google' + ? GEMINI_INLINE_MIME_TYPES.has(mimeType) || isTextDocumentMimeType(mimeType) + : provider === 'bedrock' + ? (contentType === 'image' && BEDROCK_IMAGE_FORMATS.has(extension)) || + (contentType === 'document' && BEDROCK_DOCUMENT_FORMATS.has(extension)) || + (contentType === 'video' && BEDROCK_VIDEO_FORMATS.has(extension)) + : provider === 'openrouter' + ? isImageMimeType(mimeType) || mimeType === PDF_MIME_TYPE + : IMAGE_ONLY_PROVIDERS.has(provider) + ? isImageMimeType(mimeType) + : !UNSUPPORTED_FILE_PROVIDERS.has(provider) + + if (!supported) { + throw new Error( + `File "${filename}" has MIME type "${mimeType}", which is not supported by provider "${providerId}". Supported attachments: ${supportedLabel}.` + ) + } +} + +export function prepareProviderAttachments( + files: UserFile[] | undefined, + providerId: ProviderId | string +): PreparedProviderAttachment[] { + if (!files || files.length === 0) return [] + + const provider = getAttachmentProvider(providerId) + if (!provider) { + throw new Error(`File attachments are not supported for provider "${providerId}"`) + } + + if (UNSUPPORTED_FILE_PROVIDERS.has(provider)) { + throw new Error( + `File attachments are not supported for provider "${providerId}" in the current adapter. Supported attachments: ${getProviderSupportedLabel(provider)}.` + ) + } + + const maxBytes = getProviderAttachmentMaxBytes(providerId) + + return files.map((file) => { + const declaredMimeType = inferAttachmentMimeType(file) + const contentType = getAttachmentContentType(declaredMimeType) + + if (!contentType) { + throw new Error( + `File "${file.name}" has MIME type "${declaredMimeType}", which is not supported by provider "${providerId}". Supported attachments: ${getProviderSupportedLabel(provider)}.` + ) + } + + if (Number.isFinite(file.size) && file.size > maxBytes) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(2) + const maxMB = (maxBytes / (1024 * 1024)).toFixed(0) + throw new Error( + `File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"` + ) + } + + if (!file.base64) { + throw new Error(`File "${file.name}" could not be read for provider "${providerId}"`) + } + + const sniffedImageMimeType = contentType === 'image' ? sniffImageMimeType(file.base64) : '' + if (contentType === 'image' && !sniffedImageMimeType) { + throw new Error( + `Image bytes in "${file.name}" are not a supported model image format (declared MIME type "${declaredMimeType}"). Supported image formats: image/jpeg, image/png, image/gif, image/webp.` + ) + } + + const mimeType = sniffedImageMimeType || declaredMimeType + const extension = getAttachmentExtension(file, mimeType) + const attachment = { + file, + filename: file.name, + mimeType, + base64: file.base64, + extension, + contentType, + } + + validateProviderSupport(attachment, provider, providerId) + + const providerMimeType = normalizeProviderMimeType(mimeType, provider) + return { + ...attachment, + providerMimeType, + dataUrl: toDataUrl(providerMimeType, file.base64), + ...(isTextDocumentMimeType(mimeType) && { + text: decodeBase64Text(file.base64, file.name), + }), + } + }) +} + +export function buildOpenAIMessageContent( + content: string | null | undefined, + files: UserFile[] | undefined, + providerId: ProviderId | string +): string | Array> { + const attachments = prepareProviderAttachments(files, providerId) + if (attachments.length === 0) return content ?? '' + + const parts: Array> = [] + if (content) { + parts.push({ type: 'input_text', text: content }) + } + + for (const attachment of attachments) { + if (attachment.contentType === 'image') { + parts.push({ type: 'input_image', image_url: attachment.dataUrl }) + } else { + parts.push({ + type: 'input_file', + filename: attachment.filename, + file_data: attachment.dataUrl, + }) + } + } + + return parts +} + +export function buildAnthropicMessageContent( + content: string | null | undefined, + files: UserFile[] | undefined, + providerId: ProviderId | string +): Array> { + const parts: Array> = [] + if (content) { + parts.push({ type: 'text', text: content }) + } + + for (const attachment of prepareProviderAttachments(files, providerId)) { + if (attachment.contentType === 'image') { + parts.push({ + type: 'image', + source: { + type: 'base64', + media_type: attachment.providerMimeType, + data: attachment.base64, + }, + }) + } else if (attachment.text) { + parts.push({ + type: 'document', + source: { + type: 'text', + media_type: 'text/plain', + data: attachment.text, + }, + title: attachment.filename, + }) + } else { + parts.push({ + type: 'document', + source: { + type: 'base64', + media_type: attachment.providerMimeType, + data: attachment.base64, + }, + title: attachment.filename, + }) + } + } + + return parts +} + +export function buildGeminiMessageParts( + content: string | null | undefined, + files: UserFile[] | undefined, + providerId: ProviderId | string +): Array> { + const parts: Array> = [] + if (content) { + parts.push({ text: content }) + } + + for (const attachment of prepareProviderAttachments(files, providerId)) { + parts.push({ + inlineData: { + mimeType: attachment.providerMimeType, + data: attachment.base64, + }, + }) + } + + return parts +} + +export function buildOpenAICompatibleChatContent( + content: string | null | undefined, + files: UserFile[] | undefined, + providerId: ProviderId | string +): string | Array> { + const attachments = prepareProviderAttachments(files, providerId) + if (attachments.length === 0) return content ?? '' + + const parts: Array> = [] + if (content) { + parts.push({ type: 'text', text: content }) + } + + for (const attachment of attachments) { + parts.push({ + type: 'image_url', + image_url: { + url: attachment.dataUrl, + }, + }) + } + + return parts +} + +export function buildOpenRouterMessageContent( + content: string | null | undefined, + files: UserFile[] | undefined, + providerId: ProviderId | string +): string | Array> { + const attachments = prepareProviderAttachments(files, providerId) + if (attachments.length === 0) return content ?? '' + + const parts: Array> = [] + if (content) { + parts.push({ type: 'text', text: content }) + } + + for (const attachment of attachments) { + if (attachment.contentType === 'image') { + parts.push({ + type: 'image_url', + image_url: { url: attachment.dataUrl }, + }) + } else { + parts.push({ + type: 'file', + file: { + filename: attachment.filename, + file_data: attachment.dataUrl, + }, + }) + } + } + + return parts +} + +function sanitizeBedrockName(filename: string): string { + const baseName = filename.replace(/\.[^/.]+$/, '').replace(/[^a-zA-Z0-9\s()[\]-]/g, ' ') + const compacted = baseName.replace(/\s+/g, ' ').trim() + return compacted || 'Document' +} + +function getBedrockDocumentFormat(attachment: PreparedProviderAttachment): string { + if (attachment.extension === 'md' || attachment.mimeType === 'text/markdown') return 'md' + if (attachment.extension === 'txt' || attachment.mimeType === 'text/plain') return 'txt' + return attachment.extension || 'txt' +} + +function getBedrockImageFormat(attachment: PreparedProviderAttachment): string { + return attachment.extension === 'jpg' ? 'jpeg' : attachment.extension +} + +export function buildBedrockMessageContent( + content: string | null | undefined, + files: UserFile[] | undefined, + providerId: ProviderId | string +): Array> { + const parts: Array> = [] + if (content) { + parts.push({ text: content }) + } + + for (const attachment of prepareProviderAttachments(files, providerId)) { + const bytes = Buffer.from(attachment.base64, 'base64') + if (attachment.contentType === 'image') { + parts.push({ + image: { + format: getBedrockImageFormat(attachment), + source: { bytes }, + }, + }) + } else if (attachment.contentType === 'video') { + parts.push({ + video: { + format: attachment.extension, + source: { bytes }, + }, + }) + } else { + parts.push({ + document: { + format: getBedrockDocumentFormat(attachment), + name: sanitizeBedrockName(attachment.filename), + source: { bytes }, + }, + }) + } + } + + return parts +} + +export function formatMessagesForProvider( + messages: ProviderMessageInput[], + providerId: ProviderId | string +): ProviderFormattedMessage[] { + return messages.map((message) => { + if (!message.files?.length || (message.role !== 'user' && message.role !== 'assistant')) { + return message as ProviderFormattedMessage + } + + const provider = getAttachmentProvider(providerId) + if (provider === 'openrouter') { + const { files: _files, ...rest } = message + return { + ...rest, + content: buildOpenRouterMessageContent(message.content, message.files, providerId), + } + } + + const { files: _files, ...rest } = message + return { + ...rest, + content: buildOpenAICompatibleChatContent(message.content, message.files, providerId), + } + }) +} diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index a5c9fcd633f..e6bb18655e4 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -90,6 +90,9 @@ async function executeChatCompletionsRequest( } if (request.messages) { + if (request.messages.some((message) => message.files?.length)) { + throw new Error('File attachments require an Azure OpenAI Responses API endpoint') + } allMessages.push(...(request.messages as ChatCompletionMessageParam[])) } diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 31c8d14cfc6..22500b84c07 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -17,6 +17,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { buildBedrockMessageContent } from '@/providers/attachments' import { checkForForcedToolUsage, createReadableStreamFromBedrockStream, @@ -178,9 +179,11 @@ export const bedrockProvider: ProviderConfig = { } } else { const role: ConversationRole = msg.role === 'assistant' ? 'assistant' : 'user' + const content = buildBedrockMessageContent(msg.content, msg.files, 'bedrock') messages.push({ role, - content: [{ text: msg.content || '' }], + // double-cast-allowed: shared attachment builder emits Bedrock Converse content blocks while keeping provider-neutral attachment types + content: content as unknown as ContentBlock[], }) } } diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index fe6f0bba76c..795cdaa498e 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import type { CerebrasResponse } from '@/providers/cerebras/types' import { createReadableStreamFromCerebrasStream } from '@/providers/cerebras/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' @@ -64,6 +65,7 @@ export const cerebrasProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'cerebras') const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -78,7 +80,7 @@ export const cerebrasProvider: ProviderConfig = { const payload: any = { model: request.model.replace('cerebras/', ''), - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens @@ -199,7 +201,7 @@ export const cerebrasProvider: ProviderConfig = { } const toolCalls = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 6f5c0612e3d..fe06798d728 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' import OpenAI from 'openai' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -67,6 +68,7 @@ export const deepseekProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'deepseek') const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -81,7 +83,7 @@ export const deepseekProvider: ProviderConfig = { const payload: any = { model: request.model, - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature @@ -209,7 +211,7 @@ export const deepseekProvider: ProviderConfig = { } const toolCalls = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let hasUsedForcedTool = false let modelTime = firstResponseTime diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 6aa336ec7b9..5b01076bf07 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -4,6 +4,7 @@ import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, @@ -108,6 +109,7 @@ export const fireworksProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'fireworks') as Message[] const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -122,7 +124,7 @@ export const fireworksProvider: ProviderConfig = { const payload: any = { model: requestedModel, - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature @@ -250,7 +252,7 @@ export const fireworksProvider: ProviderConfig = { } const toolCalls: FunctionCallResponse[] = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime let toolsTime = 0 diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 511a8df7ad7..6536d6336f6 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -917,7 +917,7 @@ export async function executeGeminiRequest( const providerStartTimeISO = new Date(providerStartTime).toISOString() try { - const { contents, tools, systemInstruction } = convertToGeminiFormat(request) + const { contents, tools, systemInstruction } = convertToGeminiFormat(request, providerType) // Build configuration const geminiConfig: GenerateContentConfig = {} diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index 31d430e2312..7489216049a 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -111,6 +111,44 @@ describe('ensureStructResponse', () => { }) describe('convertToGeminiFormat', () => { + it('should convert user message files to inline data parts', () => { + const request: ProviderRequest = { + model: 'gemini-2.5-flash', + messages: [ + { + role: 'user', + content: 'Analyze this image', + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', + size: 128, + type: 'image/png', + base64: 'iVBORw0KGgo=', + }, + ], + }, + ], + } + + const result = convertToGeminiFormat(request) + + expect(result.contents[0]).toEqual({ + role: 'user', + parts: [ + { text: 'Analyze this image' }, + { + inlineData: { + mimeType: 'image/png', + data: 'iVBORw0KGgo=', + }, + }, + ], + }) + }) + describe('tool message handling', () => { it('should convert tool message with object response correctly', () => { const request: ProviderRequest = { diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 3f6e37ae927..089f7dac552 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -14,6 +14,7 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { buildGeminiMessageParts } from '@/providers/attachments' import type { ProviderRequest } from '@/providers/types' import { trackForcedToolUsage } from '@/providers/utils' @@ -166,7 +167,10 @@ export interface GeminiToolDef { /** * Converts OpenAI-style request format to Gemini format */ -export function convertToGeminiFormat(request: ProviderRequest): { +export function convertToGeminiFormat( + request: ProviderRequest, + providerId = 'google' +): { contents: Content[] tools: GeminiToolDef[] | undefined systemInstruction: Content | undefined @@ -192,9 +196,10 @@ export function convertToGeminiFormat(request: ProviderRequest): { } } else if (message.role === 'user' || message.role === 'assistant') { const geminiRole = message.role === 'user' ? 'user' : 'model' + const parts = buildGeminiMessageParts(message.content, message.files, providerId) as Part[] - if (message.content) { - contents.push({ role: geminiRole, parts: [{ text: message.content }] }) + if (parts.length > 0) { + contents.push({ role: geminiRole, parts }) } if (message.role === 'assistant' && message.tool_calls?.length) { diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 192e1412d94..4165724bec8 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' import { Groq } from 'groq-sdk' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -60,6 +61,7 @@ export const groqProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'groq') const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -74,7 +76,7 @@ export const groqProvider: ProviderConfig = { const payload: any = { model: request.model.replace('groq/', ''), - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature @@ -205,7 +207,7 @@ export const groqProvider: ProviderConfig = { } const toolCalls = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime let toolsTime = 0 diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index ffe1ecad930..ce8959f3048 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -4,6 +4,7 @@ import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -77,6 +78,7 @@ export const mistralProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'mistral') const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -91,7 +93,7 @@ export const mistralProvider: ProviderConfig = { const payload: any = { model: request.model, - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature @@ -262,7 +264,7 @@ export const mistralProvider: ProviderConfig = { } const toolCalls = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 045dd1d462a..111956140c8 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -5,6 +5,7 @@ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/ import { getOllamaUrl } from '@/lib/core/utils/urls' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import type { ModelsObject } from '@/providers/ollama/types' import { createReadableStreamFromOllamaStream } from '@/providers/ollama/utils' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -91,6 +92,7 @@ export const ollamaProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'ollama') const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -105,7 +107,7 @@ export const ollamaProvider: ProviderConfig = { const payload: any = { model: request.model, - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature @@ -274,7 +276,7 @@ export const ollamaProvider: ProviderConfig = { } const toolCalls = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 1f025269235..1b2591d22e2 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -133,7 +133,7 @@ export async function executeResponsesProviderRequest( allMessages.push(...request.messages) } - const initialInput = buildResponsesInputFromMessages(allMessages) + const initialInput = buildResponsesInputFromMessages(allMessages, config.providerId) const basePayload: Record = { model: config.modelName, diff --git a/apps/sim/providers/openai/utils.test.ts b/apps/sim/providers/openai/utils.test.ts new file mode 100644 index 00000000000..20fcd484ad8 --- /dev/null +++ b/apps/sim/providers/openai/utils.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildResponsesInputFromMessages } from '@/providers/openai/utils' + +describe('buildResponsesInputFromMessages', () => { + it('should convert user message files to Responses multipart content', () => { + const input = buildResponsesInputFromMessages([ + { + role: 'user', + content: 'Analyze this image', + files: [ + { + id: 'file-1', + key: 'workspace/ws-1/example.png', + name: 'example.png', + url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', + size: 128, + type: 'image/png', + base64: 'iVBORw0KGgo=', + }, + ], + }, + ]) + + expect(input).toEqual([ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Analyze this image' }, + { + type: 'input_image', + image_url: 'data:image/png;base64,iVBORw0KGgo=', + }, + ], + }, + ]) + }) +}) diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index a1edfd9eae2..ad6a5215c19 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import type OpenAI from 'openai' +import { buildOpenAIMessageContent } from '@/providers/attachments' import type { Message } from '@/providers/types' const logger = createLogger('ResponsesUtils') @@ -21,7 +22,7 @@ export interface ResponsesToolCall { export type ResponsesInputItem = | { role: 'system' | 'user' | 'assistant' - content: string + content: string | Array> } | { type: 'function_call' @@ -45,7 +46,10 @@ export interface ResponsesToolDefinition { /** * Converts chat-style messages into Responses API input items. */ -export function buildResponsesInputFromMessages(messages: Message[]): ResponsesInputItem[] { +export function buildResponsesInputFromMessages( + messages: Message[], + providerId = 'openai' +): ResponsesInputItem[] { const input: ResponsesInputItem[] = [] for (const message of messages) { @@ -58,13 +62,21 @@ export function buildResponsesInputFromMessages(messages: Message[]): ResponsesI continue } - if ( - message.content && - (message.role === 'system' || message.role === 'user' || message.role === 'assistant') - ) { + if (message.role === 'system' || message.role === 'user' || message.role === 'assistant') { + const content = + message.role === 'user' + ? buildOpenAIMessageContent(message.content, message.files, providerId) + : (message.content ?? '') + if ( + (typeof content === 'string' && !content) || + (Array.isArray(content) && content.length === 0) + ) { + continue + } + input.push({ role: message.role, - content: message.content, + content, }) } diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 87ff07fcfef..09a8adbbdd5 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -4,6 +4,7 @@ import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { checkForForcedToolUsage, @@ -109,6 +110,7 @@ export const openRouterProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'openrouter') as Message[] const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -123,7 +125,7 @@ export const openRouterProvider: ProviderConfig = { const payload: any = { model: requestedModel, - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature @@ -251,7 +253,7 @@ export const openRouterProvider: ProviderConfig = { } const toolCalls: FunctionCallResponse[] = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime let toolsTime = 0 diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 9ce73bffcef..007b9b3ead5 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -1,4 +1,4 @@ -import type { ProviderTimingSegment, StreamingExecution } from '@/executor/types' +import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types' export type ProviderId = | 'openai' @@ -121,6 +121,7 @@ export interface ProviderToolConfig { export interface Message { role: 'system' | 'user' | 'assistant' | 'function' | 'tool' content: string | null + files?: UserFile[] name?: string function_call?: { name: string diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index db25ba45ec0..b182eefcd91 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -5,6 +5,7 @@ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/ import { env } from '@/lib/core/config/env' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -123,6 +124,7 @@ export const vllmProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'vllm') as Message[] const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -137,7 +139,7 @@ export const vllmProvider: ProviderConfig = { const payload: any = { model: request.model.replace(/^vllm\//, ''), - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature @@ -319,7 +321,7 @@ export const vllmProvider: ProviderConfig = { } const toolCalls = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 309a9fd8f3b..bd6303007df 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -4,6 +4,7 @@ import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -76,6 +77,7 @@ export const xAIProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } + const formattedMessages = formatMessagesForProvider(allMessages, 'xai') as Message[] const tools = request.tools?.length ? request.tools.map((tool) => ({ type: 'function', @@ -93,7 +95,7 @@ export const xAIProvider: ProviderConfig = { } const basePayload: any = { model: request.model, - messages: allMessages, + messages: formattedMessages, } if (request.temperature !== undefined) basePayload.temperature = request.temperature @@ -219,7 +221,7 @@ export const xAIProvider: ProviderConfig = { } const toolCalls = [] const toolResults: Record[] = [] - const currentMessages = [...allMessages] + const currentMessages = [...formattedMessages] let iterationCount = 0 let hasUsedForcedTool = false diff --git a/apps/sim/tools/file/get.ts b/apps/sim/tools/file/get.ts index 6f05dbca7d2..3143e270343 100644 --- a/apps/sim/tools/file/get.ts +++ b/apps/sim/tools/file/get.ts @@ -7,6 +7,64 @@ interface FileGetParams { _context?: WorkflowToolExecutionContext } +interface FileReadParams { + fileId?: string | string[] + fileInput?: unknown + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +const createFileReadTool = (config: { + id: 'file_read' + name: string + description: string +}): ToolConfig => ({ + id: config.id, + name: config.name, + description: config.description, + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canonical workspace file ID, or an array of canonical workspace file IDs.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected workspace file object.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'read', + fileId: params.fileId, + fileInput: params.fileInput, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to get file' } + } + return { success: true, output: data.data } + }, + + outputs: { + file: { type: 'file', description: 'Workspace file object' }, + files: { type: 'file[]', description: 'Workspace file objects' }, + }, +}) + export const fileGetTool: ToolConfig = { id: 'file_get', name: 'File Get', @@ -52,3 +110,9 @@ export const fileGetTool: ToolConfig = { file: { type: 'file', description: 'Workspace file object' }, }, } + +export const fileReadTool = createFileReadTool({ + id: 'file_read', + name: 'File Read', + description: 'Read workspace file objects from selected files or canonical workspace file IDs.', +}) diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 4e0b6daed0c..cde0e491b63 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -1,9 +1,15 @@ -import { fileParserTool, fileParserV2Tool, fileParserV3Tool } from '@/tools/file/parser' +import { + fileFetchTool, + fileParserTool, + fileParserV2Tool, + fileParserV3Tool, +} from '@/tools/file/parser' export { fileAppendTool } from '@/tools/file/append' -export { fileGetTool } from '@/tools/file/get' +export { fileGetTool, fileReadTool } from '@/tools/file/get' export { fileWriteTool } from '@/tools/file/write' export const fileParseTool = fileParserTool +export { fileFetchTool } export { fileParserV2Tool } export { fileParserV3Tool } diff --git a/apps/sim/tools/file/parser.ts b/apps/sim/tools/file/parser.ts index 5118e3f3a2d..d98e08de653 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -10,6 +10,7 @@ import type { FileParserV3OutputData, FileUploadInput, } from '@/tools/file/types' +import { transformTable } from '@/tools/shared/table' import type { ToolConfig } from '@/tools/types' const logger = createLogger('FileParserTool') @@ -34,6 +35,20 @@ const isFileParseResult = (value: unknown): value is FileParseResult => typeof value.name === 'string' && typeof value.binary === 'boolean' +const normalizeHeaders = (headers: FileParserInput['headers']): Record => { + const transformed = transformTable(headers ?? null) + return Object.entries(transformed).reduce( + (acc, [key, value]) => { + const headerName = key.trim() + if (headerName && value !== undefined && value !== null) { + acc[headerName] = String(value) + } + return acc + }, + {} as Record + ) +} + const normalizeFileParseResult = (value: unknown): FileParseResult => { if (isRecord(value) && isFileParseResult(value.output)) { return value.output @@ -245,9 +260,11 @@ export const fileParserTool: ToolConfig = { } logger.info('Tool body determined filePath:', determinedFilePath) + const headers = normalizeHeaders(params.headers) return { filePath: determinedFilePath, fileType: determinedFileType, + ...(Object.keys(headers).length > 0 && { headers }), workspaceId: params.workspaceId || params._context?.workspaceId, workflowId: params._context?.workflowId, executionId: params._context?.executionId, @@ -286,6 +303,25 @@ export const fileParserV2Tool: ToolConfig = { }, } +const parseFileParserV3Response = async (response: Response): Promise => { + const parsed = await parseFileParserResponse(response) + const output = parsed.output as FileParserOutputData + const files = + Array.isArray(output.processedFiles) && output.processedFiles.length > 0 + ? output.processedFiles + : [] + + const cleanedOutput: FileParserV3OutputData = { + files, + combinedContent: output.combinedContent, + } + + return { + success: true, + output: cleanedOutput, + } +} + export const fileParserV3Tool: ToolConfig = { id: 'file_parser_v3', name: 'File Parser', @@ -293,26 +329,31 @@ export const fileParserV3Tool: ToolConfig = version: '3.0.0', params: fileParserTool.params, request: fileParserTool.request, - transformResponse: async (response: Response): Promise => { - const parsed = await parseFileParserResponse(response) - const output = parsed.output as FileParserOutputData - const files = - Array.isArray(output.processedFiles) && output.processedFiles.length > 0 - ? output.processedFiles - : [] - - const cleanedOutput: FileParserV3OutputData = { - files, - combinedContent: output.combinedContent, - } - - return { - success: true, - output: cleanedOutput, - } - }, + transformResponse: parseFileParserV3Response, outputs: { files: { type: 'file[]', description: 'Parsed files as UserFile objects' }, combinedContent: { type: 'string', description: 'Combined content of all parsed files' }, }, } + +export const fileFetchTool: ToolConfig = { + id: 'file_fetch', + name: 'File Fetch', + description: 'Fetch and parse a file from a URL with optional custom headers.', + version: '1.0.0', + params: { + ...fileParserTool.params, + headers: { + type: 'object', + required: false, + visibility: 'user-or-llm', + description: 'HTTP headers to include when fetching URL-based files.', + }, + }, + request: fileParserTool.request, + transformResponse: parseFileParserV3Response, + outputs: { + files: { type: 'file[]', description: 'Fetched files as UserFile objects' }, + combinedContent: { type: 'string', description: 'Combined content of all fetched files' }, + }, +} diff --git a/apps/sim/tools/file/types.ts b/apps/sim/tools/file/types.ts index 73553c6e02f..7943c470444 100644 --- a/apps/sim/tools/file/types.ts +++ b/apps/sim/tools/file/types.ts @@ -1,10 +1,11 @@ import type { UserFile } from '@/executor/types' -import type { ToolResponse } from '@/tools/types' +import type { TableRow, ToolResponse } from '@/tools/types' export interface FileParserInput { filePath?: string | string[] file?: UserFile | UserFile[] | FileUploadInput | FileUploadInput[] fileType?: string + headers?: TableRow[] | Record | string | null workspaceId?: string workflowId?: string executionId?: string diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index c71385aa0d9..52e6db1b6bc 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -645,10 +645,12 @@ import { } from '@/tools/fathom' import { fileAppendTool, + fileFetchTool, fileGetTool, fileParserV2Tool, fileParserV3Tool, fileParseTool, + fileReadTool, fileWriteTool, } from '@/tools/file' import { @@ -3216,7 +3218,9 @@ export const tools: Record = { file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, file_append: fileAppendTool, + file_fetch: fileFetchTool, file_get: fileGetTool, + file_read: fileReadTool, file_write: fileWriteTool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool, From c9118e775bf0d5e3bba2be1089650f3a75265448 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 15 May 2026 12:05:03 -0700 Subject: [PATCH 03/16] feat(files): folders, multiselect, vfs update (#4572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * feat(files): folders + vfs update * address comments * address comments * cleanup unnused code * address comments * perf improvements * address next set * cycle detect * error handling * path improvements * cleanup, best practices * react query best practices: targeted invalidation, optimistic updates, key factory hierarchy - Add workspaceLists(workspaceId) intermediate key level to both workspaceFilesKeys and workspaceFileFolderKeys so invalidation targets only the affected workspace instead of all workspaces - Replace all lists() invalidation calls with workspaceLists(workspaceId) across every mutation (upload, rename, delete, restore, update content, folder mutations) - Add optimistic updates to useRenameWorkspaceFile and useUpdateWorkspaceFileFolder with onMutate snapshot, onError rollback, onSettled reconciliation - Move storage key into the content() factory as optional param so query keys are always built through the factory (useWorkspaceFileContent, useWorkspaceFileBinary) - Fix AnimatePresence wrapping in FilesActionBar so exit animation fires on deselect - Fix ResourceColGroup to use percentage weights instead of pixel widths to prevent horizontal scroll on narrow viewports * add shift-click range selection and selection-aware context menu for files - Extend SelectableConfig.onSelectRow with optional shiftKey param; DataRow captures shiftKey before onCheckedChange fires via a ref so the Radix Checkbox interaction chain stays intact - Implement shift-click range selection in files.tsx using lastSelectedIndexRef; tracks last-selected index in visibleRowIds to compute the range - Reset lastSelectedIndexRef on deselect and select-all - Add selectedCount prop to FileRowContextMenu; hide Open and Rename when multiple items are selected, show "Delete N items" / "Download N items" labels in multi-select mode * add Move submenu to file context menu and fix shift-click anchor update - Add nested Move submenu to FileRowContextMenu using DropdownMenuSub/SubTrigger/SubContent; shows available folders filtered by selection, converts '__root__' -> null for moving to the root level - Add handleContextMenuMove in files.tsx that calls moveItems.mutateAsync directly (no modal) and clears selection on success - Fix shift-click range selection: update lastSelectedIndexRef after range select so chained shift-clicks extend from the new anchor point correctly * fix move submenu: use folder names with tree-ordered indentation instead of stale paths - Compute folder depth from parentId chain client-side (avoids stale server-computed path field) - Tree-order folders so parents appear before their children, sorted by sortOrder then name - Show folder.name instead of folder.path so optimistic renames are reflected immediately - Indent each folder by depth * 12px in the submenu so po/shit renders as 'shit' indented under 'po' - MoveOption gains optional depth field; contextMenuMoveOptions is a separate memo from moveFolderOptions (modal keeps its existing path-label behavior) * fix shift-click anchor drift and remove dead stopPropagation constant - Remove dead stopPropagation const in resource.tsx (replaced by handleSelectRowClick) - Reset lastSelectedIndexRef when visibleRowIds changes so search/filter/folder navigation doesn't leave a stale anchor that produces wrong ranges on the next shift-click - Update lastSelectedIndexRef in handleRowContextMenu when right-clicking resets selection to a single item, so the anchor matches the newly-selected row - Add visibleRowIds to handleRowContextMenu deps (now reads it to compute anchor index) - Remove moveItems.mutateAsync from handleContextMenuMove deps per project convention (.mutateAsync is stable in TanStack v5) * complete workspace files feature: audit logs, posthog events, folder restore, empty state, keyboard shortcuts, storage indicator, breadcrumb rename - Audit + PostHog: wire file_renamed, file_deleted, file_moved, file_bulk_deleted, folder_created, folder_renamed, folder_deleted, folder_moved events to all file/folder API routes - Add AuditAction.FOLDER_UPDATED, FILE_MOVED, FOLDER_MOVED to audit types - Folder restore: server function, contract, API route (POST /files/folders/[folderId]/restore), hook (useRestoreWorkspaceFileFolder), Recently Deleted integration with new File Folders tab - Empty state: contextual emptyMessage passed to based on search/filters/folder context - Keyboard shortcuts: Delete/Backspace deletes selection, Escape deselects, Cmd+A selects all (list view only, input-aware guard) - Storage indicator: useStorageInfo drives compact "used / limit" display in file list header via leadingActions - Breadcrumb rename: current folder breadcrumb gains Rename dropdown + inline editing via breadcrumbRename (useInlineRename) - Resource: thread leadingActions prop from ResourceProps to ResourceHeader * cleanup: accessibility, emcn design tokens, react best practices across workspace UI - Add sr-only ModalDescription to dialogs/modals for accessibility - Replace hardcoded colors and z-indices with design token CSS variables - Apply emcn design review fixes across tables, knowledge, logs, settings, workflows * fix audit and posthog: FOLDER_RESTORED action on restore, fire folder_moved event separately from file_moved * sidebar: add Files section with nested folder tree; polish move UX and cleanup - Files section in sidebar shows folder/file tree with expand/collapse, matching Workflows section structure; collapsed sidebar shows flyout menu - Move action bar now uses nested DropdownMenuSub tree instead of flat modal - Context menu and action bar share renderMoveOption from move-options.tsx - FolderInput added to emcn icons barrel; all FolderInput imports migrated - Drag ghost uses CSS vars (--border, --shadow-medium, --z-toast) - Selection pruning converted from useEffect to render-time comparison - Keyboard listener stabilized with handleBulkDeleteRef pattern - toError() used consistently in restore and move route handlers * remove Files section from sidebar * restore Files nav item in sidebar workspace section * fix infinite re-render on files page - revert selection pruning to useEffect * add filefolder resource type for ingesting workspace file folders * export filefolder tree types; add toast feedback for file/folder mutations * regenerate migration as 0208 after rebase onto staging * add workspaceFileFolder to schema mock * add FILE_MOVED, FOLDER_MOVED, FOLDER_UPDATED to audit mock * add filefolder ChatContext kind and wire through schema and resolver * add filefolder to AgentContextType * add filefolder to chat context kind registry; fix resolver to use workspaceFiles table * add .deepsec to gitignore * cleanup: effect, emcn tokens, mutation error handling - Replace selection-pruning useEffect with inline state adjustment during render - Fix drag overlay using invalid --accent HSL token → --brand-secondary; z-50 → z-[var(--z-dropdown)] - Move static inline styles on context menu trigger div to className - Add missing onError toast to useUpdateWorkspaceFileFolder, useRestoreWorkspaceFileFolder, useRestoreWorkspaceFile * lint * fix: remove duplicate handleCopilotStopGeneration from rebase * feat(copilot): folder-aware file context in WORKSPACE.md * feat(copilot): add move operation to file manage API * fix(files): make targetFolder optional in move file contract * perf(files): parallelize buffer fetches, fix N+1 folder queries, stabilize drag useMemo - download route: fan out all fetchWorkspaceFileBuffer calls with Promise.all before zip assembly so 100 files resolve in one round-trip instead of sequentially - getWorkspaceFileFolder: replace per-ancestor SELECTs with a single workspace-wide folder load + buildWorkspaceFileFolderPathMap, making depth irrelevant to query count - ensureWorkspaceFileFolderPath: pre-load all workspace folders in one SELECT before the segment loop; resolve existing segments from an in-memory map; only hit the DB to CREATE missing segments; conflict retry path preserved and also updates the map - files.tsx rowDragDropConfig: move activeDropTargetId into a ref so the useMemo does not recompute on every drag-over event * fix(files): remove files/ path stripping, fix stale path in optimistic update - splitWorkspaceFilePath: remove the unconditional .replace(/^files\//, '') that clobbered paths for files inside a folder literally named "files" - useUpdateWorkspaceFileFolder: when a name update is in flight, recompute the path field for the renamed folder (replace last segment) and propagate the new prefix to all descendant folders so breadcrumbs stay correct during the optimistic window * fix(files): revert broken ref opt, clean 409 on restore, null parentId on orphaned restore - files.tsx: revert the activeDropTargetId ref optimization — the ref doesn't trigger re-renders so the drop-target highlight never updated during drag; activeDropTargetId is back in state and in the rowDragDropConfig deps - restore/route.ts: catch Postgres 23505 unique-constraint violation and return a clean 409 instead of leaking the raw error as 400 - restoreWorkspaceFileFolder: check if the parent folder is still archived before restoring; if it is, restore to root (parentId: null) so the folder is never orphaned under an archived parent * feat(search): show folder path for files in cmd-k modal, strip extraneous comments - FileItem interface with folderPath?: string[] added to search modal utils - MemoizedFileItem component renders folder breadcrumb identically to MemoizedWorkflowItem — truncated path segments on the right with / separators - FilesGroup rewritten as a dedicated memo component (was createIconGroup factory) so it accepts FileItem[] and includes folderPath segments in the search value - searchModalFiles in sidebar splits f.folderPath string into string[] segments - search-modal.tsx typed to FileItem and includes folderPath in filterAndSort - Remove self-explanatory "Phase 1" section label from download route - Remove redundant TSDoc on the unique index in db schema * fix(workspace-files): audit fixes — transaction, status codes, contract refinements, guards * fix(vfs): pass folderPath separately so buildWorkspaceMd groups files correctly * fix(types): narrow unknown fileInput with Record cast after object guard * fix(routes): replace instanceof Error with toError() across new workspace file routes * improvement(files): cleanup pass — remove unnecessary useCallbacks, consolidate emcn icon imports - Remove useCallback from 5 drag-event handlers in DataRow (passed to native elements, no observer) - Remove stable useCallback fns from 3 useMemo deps arrays in files.tsx (editingId/editValue remain) - Merge all @/components/emcn/icons subpath imports into barrel (files.tsx, action-bar, file-row-context-menu) * fix(files): apply activeSort to folders, reject drop onto current parent folder - visibleFolders now respects activeSort column (name/updated/created) and direction so folder ordering stays consistent with file ordering - isInvalidDropTarget now returns true when all dragged items are already direct children of the target folder, preventing a no-op move mutation * fix breadcrumb * add new tools to rename, create, delete folders * move more ui actions into orchestration dir * address comments * fix params * fix tests * address comments * improve error codes * address comments * address more nits * fix mcp server error code --------- Co-authored-by: Theodore Li Co-authored-by: waleed --- .gitignore | 1 + .../components/auth-modal/auth-modal.tsx | 4 + .../demo-request/demo-request-modal.tsx | 4 + .../components/request-integration-modal.tsx | 8 + apps/sim/app/api/credentials/[id]/route.ts | 275 +--- apps/sim/app/api/folders/[id]/route.test.ts | 83 +- apps/sim/app/api/folders/[id]/route.ts | 49 +- apps/sim/app/api/folders/route.test.ts | 29 +- apps/sim/app/api/folders/route.ts | 103 +- .../app/api/knowledge/[id]/restore/route.ts | 51 +- apps/sim/app/api/mcp/servers/[id]/route.ts | 126 +- apps/sim/app/api/mcp/servers/route.test.ts | 89 ++ apps/sim/app/api/mcp/servers/route.ts | 226 +-- .../api/mcp/workflow-servers/[id]/route.ts | 111 +- .../[id]/tools/[toolId]/route.ts | 142 +- .../mcp/workflow-servers/[id]/tools/route.ts | 146 +- .../sim/app/api/mcp/workflow-servers/route.ts | 127 +- apps/sim/app/api/schedules/[id]/route.ts | 86 +- apps/sim/app/api/schedules/route.ts | 92 +- .../app/api/table/[tableId]/restore/route.ts | 35 +- apps/sim/app/api/table/route.ts | 49 +- apps/sim/app/api/tools/file/manage/route.ts | 73 +- apps/sim/app/api/v1/files/[fileId]/route.ts | 29 +- .../app/api/workflows/[id]/restore/route.ts | 32 +- apps/sim/app/api/workflows/[id]/route.test.ts | 39 +- apps/sim/app/api/workflows/[id]/route.ts | 87 +- apps/sim/app/api/workflows/route.test.ts | 2 +- apps/sim/app/api/workflows/route.ts | 177 +-- .../app/api/workspaces/[id]/api-keys/route.ts | 89 +- .../[id]/files/[fileId]/restore/route.ts | 31 +- .../workspaces/[id]/files/[fileId]/route.ts | 82 +- .../[id]/files/bulk-archive/route.ts | 70 + .../workspaces/[id]/files/download/route.ts | 150 ++ .../files/folders/[folderId]/restore/route.ts | 66 + .../[id]/files/folders/[folderId]/route.ts | 121 ++ .../workspaces/[id]/files/folders/route.ts | 88 ++ .../api/workspaces/[id]/files/move/route.ts | 78 + .../workspaces/[id]/files/presigned/route.ts | 15 +- .../workspaces/[id]/files/register/route.ts | 3 +- .../app/api/workspaces/[id]/files/route.ts | 6 +- apps/sim/app/playground/page.tsx | 8 +- .../[workspaceId]/components/index.ts | 1 + .../message-actions/message-actions.tsx | 4 + .../[workspaceId]/components/oauth-modal.tsx | 4 + .../components/resource/resource.tsx | 133 +- .../components/action-bar/action-bar.tsx | 127 ++ .../files/components/action-bar/index.ts | 1 + .../delete-confirm-modal.tsx | 70 + .../components/delete-confirm-modal/index.ts | 1 + .../file-row-context-menu.tsx | 114 ++ .../components/file-row-context-menu/index.ts | 1 + .../components/file-viewer/preview-panel.tsx | 2 +- .../files-list-context-menu.tsx | 22 +- .../workspace/[workspaceId]/files/files.tsx | 1319 ++++++++++++----- .../[workspaceId]/files/move-options.tsx | 42 + .../components/chat-context-kind-registry.tsx | 4 + .../add-resource-dropdown.tsx | 127 +- .../components/add-resource-dropdown/index.ts | 3 + .../resource-registry/resource-registry.tsx | 13 + .../user-input/components/constants.ts | 1 + .../components/plus-menu-dropdown.tsx | 30 +- .../delete-chunk-modal/delete-chunk-modal.tsx | 14 +- .../document-tags-modal.tsx | 4 + .../knowledge/[id]/[documentId]/document.tsx | 9 +- .../[workspaceId]/knowledge/[id]/base.tsx | 16 +- .../add-connector-modal.tsx | 6 + .../add-documents-modal.tsx | 4 + .../base-tags-modal/base-tags-modal.tsx | 12 +- .../connectors-section/connectors-section.tsx | 5 +- .../edit-connector-modal.tsx | 4 + .../rename-document-modal.tsx | 2 + .../create-base-modal/create-base-modal.tsx | 4 + .../delete-knowledge-base-modal.tsx | 14 +- .../edit-knowledge-base-modal.tsx | 4 + .../execution-snapshot/execution-snapshot.tsx | 8 +- .../notifications/notifications.tsx | 8 +- .../create-schedule-modal/schedule-modal.tsx | 4 + .../scheduled-tasks/scheduled-tasks.tsx | 5 +- .../settings/components/api-keys/api-keys.tsx | 5 +- .../create-api-key-modal.tsx | 9 +- .../settings/components/byok/byok.tsx | 9 +- .../settings/components/copilot/copilot.tsx | 13 +- .../credential-sets/credential-sets.tsx | 12 +- .../components/custom-tools/custom-tools.tsx | 14 +- .../settings/components/general/general.tsx | 5 +- .../components/inbox/inbox-enable-toggle.tsx | 9 +- .../components/inbox/inbox-settings-tab.tsx | 8 +- .../integrations/integrations-manager.tsx | 15 +- .../mcp-server-form-modal.tsx | 5 + .../settings/components/mcp/mcp.tsx | 5 +- .../recently-deleted/recently-deleted.tsx | 168 ++- .../components/secrets/secrets-manager.tsx | 13 +- .../skills/components/skill-modal.tsx | 11 +- .../settings/components/skills/skills.tsx | 14 +- .../credit-balance/credit-balance.tsx | 4 + .../components/subscription/subscription.tsx | 9 +- .../remove-member-dialog.tsx | 5 +- .../components/team-seats/team-seats.tsx | 5 +- .../transfer-ownership-dialog.tsx | 4 + .../create-workflow-mcp-server-modal.tsx | 5 + .../workflow-mcp-servers.tsx | 19 +- .../components/row-modal/row-modal.tsx | 8 +- .../[workspaceId]/tables/[tableId]/table.tsx | 9 +- .../import-csv-dialog/import-csv-dialog.tsx | 4 + .../workspace/[workspaceId]/tables/tables.tsx | 5 +- .../deploy-modal/components/chat/chat.tsx | 5 +- .../general/components/api-info-modal.tsx | 9 +- .../components/version-description-modal.tsx | 9 +- .../components/general/general.tsx | 12 +- .../components/template/template.tsx | 5 +- .../components/deploy-modal/deploy-modal.tsx | 13 +- .../grouped-checkbox-list.tsx | 5 +- .../custom-tool-modal/custom-tool-modal.tsx | 13 +- .../w/[workflowId]/components/panel/panel.tsx | 5 +- .../collapsed-sidebar-menu.tsx | 98 ++ .../components/file-list/file-list.tsx | 214 +++ .../components/help-modal/help-modal.tsx | 4 + .../w/components/sidebar/components/index.ts | 2 + .../search-modal/components/command-items.tsx | 45 + .../search-modal/components/search-groups.tsx | 29 +- .../components/search-modal/search-modal.tsx | 20 +- .../sidebar/components/search-modal/utils.ts | 9 +- .../settings-sidebar/settings-sidebar.tsx | 5 +- .../components/delete-modal/delete-modal.tsx | 14 +- .../create-workspace-modal.tsx | 4 + .../components/invite-modal/invite-modal.tsx | 12 +- .../workspace-header/workspace-header.tsx | 5 +- .../w/components/sidebar/sidebar.tsx | 43 +- .../emcn/components/wizard/wizard.tsx | 14 +- .../components/emcn/icons/folder-input.tsx | 37 + apps/sim/components/emcn/icons/index.ts | 1 + .../components/access-control.tsx | 19 +- .../components/data-drains-settings.tsx | 4 + apps/sim/hooks/queries/tables.test.ts | 41 + apps/sim/hooks/queries/tables.ts | 12 +- .../hooks/queries/workspace-file-folders.ts | 224 +++ apps/sim/hooks/queries/workspace-files.ts | 95 +- apps/sim/lib/api-key/orchestration/index.ts | 82 + apps/sim/lib/api/contracts/index.ts | 1 + apps/sim/lib/api/contracts/tables.ts | 2 +- apps/sim/lib/api/contracts/tools/file.ts | 14 +- .../api/contracts/workspace-file-folders.ts | 203 +++ apps/sim/lib/api/contracts/workspace-files.ts | 21 +- apps/sim/lib/copilot/chat/post.ts | 17 +- apps/sim/lib/copilot/chat/process-contents.ts | 56 + .../sim/lib/copilot/chat/workspace-context.ts | 35 +- .../lib/copilot/generated/tool-catalog-v1.ts | 190 ++- .../lib/copilot/generated/tool-schemas-v1.ts | 326 ++-- apps/sim/lib/copilot/resources/types.ts | 1 + .../tools/handlers/deployment/deploy.ts | 90 +- .../tools/handlers/deployment/manage.ts | 163 +- apps/sim/lib/copilot/tools/handlers/jobs.ts | 300 +--- .../handlers/management/manage-credential.ts | 53 +- .../handlers/management/manage-mcp-tool.ts | 136 +- .../lib/copilot/tools/handlers/param-types.ts | 6 +- .../tools/handlers/restore-resource.ts | 132 +- .../tools/handlers/workflow/mutations.test.ts | 13 +- .../tools/handlers/workflow/mutations.ts | 190 ++- .../copilot/tools/server/files/create-file.ts | 24 +- .../copilot/tools/server/files/delete-file.ts | 34 +- .../files/download-to-workspace-file.ts | 2 + .../tools/server/files/file-folders.ts | 377 +++++ .../copilot/tools/server/files/rename-file.ts | 18 +- .../tools/server/files/workspace-file.ts | 69 +- apps/sim/lib/copilot/tools/server/router.ts | 43 +- apps/sim/lib/copilot/vfs/serializers.ts | 6 + apps/sim/lib/copilot/vfs/workspace-vfs.ts | 95 +- .../lib/credentials/orchestration/index.ts | 324 ++++ apps/sim/lib/knowledge/orchestration/index.ts | 88 ++ apps/sim/lib/mcp/orchestration/index.ts | 31 + .../lib/mcp/orchestration/server-lifecycle.ts | 331 +++++ .../orchestration/workflow-mcp-lifecycle.ts | 575 +++++++ apps/sim/lib/mcp/utils.ts | 10 + apps/sim/lib/posthog/events.ts | 30 + apps/sim/lib/resources/orchestration/index.ts | 5 + .../orchestration/restore-resource.ts | 179 +++ apps/sim/lib/table/orchestration/index.ts | 64 + apps/sim/lib/uploads/client/direct-upload.ts | 14 +- .../lib/uploads/contexts/workspace/index.ts | 1 + .../workspace-file-folder-manager.test.ts | 33 + .../workspace-file-folder-manager.ts | 1134 ++++++++++++++ .../workspace/workspace-file-manager.test.ts | 27 + .../workspace/workspace-file-manager.ts | 245 +-- apps/sim/lib/uploads/core/storage-service.ts | 1 + apps/sim/lib/uploads/server/metadata.ts | 6 +- apps/sim/lib/uploads/utils/file-utils.ts | 5 +- .../sim/lib/workflows/orchestration/deploy.ts | 138 +- .../orchestration/folder-lifecycle.ts | 174 ++- apps/sim/lib/workflows/orchestration/index.ts | 14 +- apps/sim/lib/workflows/orchestration/types.ts | 2 +- .../orchestration/workflow-lifecycle.ts | 366 ++++- .../workflows/schedules/orchestration.test.ts | 125 ++ .../lib/workflows/schedules/orchestration.ts | 404 +++++ .../orchestration/file-folder-lifecycle.ts | 457 ++++++ .../workspace-files/orchestration/index.ts | 25 + apps/sim/stores/panel/types.ts | 1 + packages/audit/src/types.ts | 3 + .../db/migrations/0208_modern_power_man.sql | 26 + ...{0207_snapshot.json => 0208_snapshot.json} | 248 +++- packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 52 +- packages/testing/src/mocks/audit.mock.ts | 3 + packages/testing/src/mocks/schema.mock.ts | 11 + .../src/mocks/workflows-orchestration.mock.ts | 8 + scripts/check-api-validation-contracts.ts | 4 +- 205 files changed, 11101 insertions(+), 3412 deletions(-) create mode 100644 apps/sim/app/api/mcp/servers/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/download/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/move/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/move-options.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx create mode 100644 apps/sim/components/emcn/icons/folder-input.tsx create mode 100644 apps/sim/hooks/queries/workspace-file-folders.ts create mode 100644 apps/sim/lib/api-key/orchestration/index.ts create mode 100644 apps/sim/lib/api/contracts/workspace-file-folders.ts create mode 100644 apps/sim/lib/copilot/tools/server/files/file-folders.ts create mode 100644 apps/sim/lib/credentials/orchestration/index.ts create mode 100644 apps/sim/lib/knowledge/orchestration/index.ts create mode 100644 apps/sim/lib/mcp/orchestration/index.ts create mode 100644 apps/sim/lib/mcp/orchestration/server-lifecycle.ts create mode 100644 apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts create mode 100644 apps/sim/lib/resources/orchestration/index.ts create mode 100644 apps/sim/lib/resources/orchestration/restore-resource.ts create mode 100644 apps/sim/lib/table/orchestration/index.ts create mode 100644 apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts create mode 100644 apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts create mode 100644 apps/sim/lib/workflows/schedules/orchestration.test.ts create mode 100644 apps/sim/lib/workflows/schedules/orchestration.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/index.ts create mode 100644 packages/db/migrations/0208_modern_power_man.sql rename packages/db/migrations/meta/{0207_snapshot.json => 0208_snapshot.json} (98%) diff --git a/.gitignore b/.gitignore index ef2e7025b0c..c0532fd4492 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ i18n.cache .claude/launch.json .claude/worktrees/ .claude/scheduled_tasks.lock +.deepsec/ diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index 369c99fedd5..f64ee69b34c 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -10,6 +10,7 @@ import { Modal, ModalClose, ModalContent, + ModalDescription, ModalTitle, ModalTrigger, } from '@/components/emcn' @@ -134,6 +135,9 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal {view === 'login' ? 'Log in' : 'Create account'} + + {view === 'login' ? 'Sign in to your account' : 'Create a new account'} +
diff --git a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx index f07e78dd4b2..8225d58cf68 100644 --- a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx +++ b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx @@ -8,6 +8,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, ModalTrigger, @@ -152,6 +153,9 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP } > + + Fill out this form to request a demo and talk to the sales team +
diff --git a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx index ab631d34957..e5fa0d5ca9f 100644 --- a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx +++ b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx @@ -8,6 +8,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Textarea, @@ -83,6 +84,9 @@ export function RequestIntegrationModal() { {status === 'success' ? ( + + Integration request submitted successfully +
+ + Submit a request for a new integration by entering the integration name and your + email +
diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 0d3939f8d25..f846a88d3fb 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,22 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' -import { deleteCredential } from '@/lib/credentials/deletion' -import { - deleteWorkspaceEnvCredentials, - syncPersonalEnvCredentialsForUser, -} from '@/lib/credentials/environment' -import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' const logger = createLogger('CredentialByIdAPI') @@ -93,93 +85,33 @@ export const PUT = withRouteHandler( const { id } = parsed.data.params const body = parsed.data.body - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) - } - - const updates: Record = {} - - if (body.description !== undefined) { - updates.description = body.description ?? null - } - - if ( - body.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') - ) { - updates.displayName = body.displayName - } - - if (body.serviceAccountJson !== undefined && access.credential.type === 'service_account') { - let parsedJson: Record - try { - parsedJson = JSON.parse(body.serviceAccountJson) - } catch { - return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }) - } - if ( - parsedJson.type !== 'service_account' || - typeof parsedJson.client_email !== 'string' || - typeof parsedJson.private_key !== 'string' || - typeof parsedJson.project_id !== 'string' - ) { - return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 }) - } - const { encrypted } = await encryptSecret(body.serviceAccountJson) - updates.encryptedServiceAccountKey = encrypted - } - - if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { - return NextResponse.json( - { - error: 'No updatable fields provided.', - }, - { status: 400 } - ) - } - return NextResponse.json( - { - error: - 'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.', - }, - { status: 400 } - ) - } - - updates.updatedAt = new Date() - await db.update(credential).set(updates).where(eq(credential.id, id)) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, + const result = await performUpdateCredential({ + credentialId: id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - metadata: { - credentialType: access.credential.type, - updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'), - }, + displayName: body.displayName, + description: body.description, + serviceAccountJson: body.serviceAccountJson, request, }) + if (!result.success) { + const status = + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'forbidden' + ? 403 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'validation' + ? 400 + : 500 + return NextResponse.json({ error: result.error }, { status }) + } const row = await getCredentialResponse(id, session.user.id) return NextResponse.json({ credential: row }, { status: 200 }) } catch (error) { - if (error instanceof Error && error.message.includes('unique')) { - return NextResponse.json( - { error: 'A service account credential with this name already exists in the workspace' }, - { status: 409 } - ) - } logger.error('Failed to update credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -196,163 +128,24 @@ export const DELETE = withRouteHandler( const { id } = await params try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) - } - - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 }) - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) { - delete current[access.credential.envKey] - } - - await db - .insert(environment) - .values({ - id: ownerUserId, - userId: ownerUserId, - variables: current, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? - {}) as Record - if (access.credential.envKey in current) { - delete current[access.credential.envKey] - } - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } - - await deleteCredential({ + const result = await performDeleteCredential({ credentialId: id, - actorId: session.user.id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - reason: 'user_delete', request, }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? id, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) + if (!result.success) { + const status = + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'forbidden' + ? 403 + : result.errorCode === 'validation' + ? 400 + : 500 + return NextResponse.json({ error: result.error }, { status }) + } return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 477ada12fce..a60e552dc06 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -34,6 +34,7 @@ const { mockLogger, mockDbRef } = vi.hoisted(() => { }) const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder +const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -54,16 +55,6 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' -/** Type for captured folder values in tests */ -interface CapturedFolderValues { - name?: string - color?: string - parentId?: string | null - isExpanded?: boolean - sortOrder?: number - updatedAt?: Date -} - interface FolderDbMockOptions { folderLookupResult?: any updateResult?: any[] @@ -160,6 +151,41 @@ describe('Individual Folder API Route', () => { success: true, deletedItems: { folders: 1, workflows: 0 }, }) + mockPerformUpdateFolder.mockImplementation(async (params) => { + if (params.parentId && params.parentId === params.folderId) { + return { + success: false, + error: 'Folder cannot be its own parent', + errorCode: 'validation', + } + } + if ( + params.parentId && + (await workflowsUtilsMockFns.mockCheckForCircularReference( + params.folderId, + params.parentId + )) + ) { + return { + success: false, + error: 'Cannot create circular folder reference', + errorCode: 'validation', + } + } + return { + success: true, + folder: { + ...mockFolder, + id: params.folderId, + name: params.name !== undefined ? params.name.trim() : 'Updated Folder', + color: params.color ?? mockFolder.color, + parentId: params.parentId ?? mockFolder.parentId, + isExpanded: params.isExpanded, + sortOrder: params.sortOrder ?? mockFolder.sortOrder, + updatedAt: new Date(), + }, + } + }) workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false) }) @@ -180,7 +206,7 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('folder') expect(data.folder).toMatchObject({ - name: 'Updated Folder', + name: 'Updated Folder Name', }) }) @@ -285,44 +311,15 @@ describe('Individual Folder API Route', () => { it('should trim folder name when updating', async () => { mockAuthenticatedUser() - let capturedUpdates: CapturedFolderValues | null = null - - const mockSelect = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - then: vi.fn().mockImplementation((callback) => { - return Promise.resolve(callback([mockFolder])) - }), - })), - })), - })) - - const mockUpdate = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation((updates) => { - capturedUpdates = updates - return { - where: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockReturnValue([{ ...mockFolder, name: 'Folder With Spaces' }]), - })), - } - }), - })) - - mockDbRef.current = { - select: mockSelect, - update: mockUpdate, - delete: vi.fn(), - } - const req = createMockRequest('PUT', { name: ' Folder With Spaces ', }) const params = Promise.resolve({ id: 'folder-1' }) - await PUT(req, { params }) + const response = await PUT(req, { params }) + const data = await response.json() - expect(capturedUpdates).not.toBeNull() - expect(capturedUpdates!.name).toBe('Folder With Spaces') + expect(data.folder.name).toBe('Folder With Spaces') }) it('should handle database errors gracefully', async () => { diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index be2b71e1028..bc622793bc4 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -9,8 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteFolder } from '@/lib/workflows/orchestration' -import { checkForCircularReference } from '@/lib/workflows/utils' +import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersIDAPI') @@ -81,39 +80,27 @@ export const PUT = withRouteHandler( await assertFolderMutable(parentId) } - // Prevent setting a folder as its own parent or creating circular references - if (parentId && parentId === id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) - } + const result = await performUpdateFolder({ + folderId: id, + workspaceId: existingFolder.workspaceId, + userId: session.user.id, + name, + color, + isExpanded, + locked, + parentId, + sortOrder, + }) - // Check for circular references if parentId is provided - if (parentId) { - const wouldCreateCycle = await checkForCircularReference(id, parentId) - if (wouldCreateCycle) { - return NextResponse.json( - { error: 'Cannot create circular folder reference' }, - { status: 400 } - ) - } + if (!result.success || !result.folder) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) } - const updates: Record = { updatedAt: new Date() } - if (name !== undefined) updates.name = name.trim() - if (color !== undefined) updates.color = color - if (isExpanded !== undefined) updates.isExpanded = isExpanded - if (locked !== undefined) updates.locked = locked - if (parentId !== undefined) updates.parentId = parentId || null - if (sortOrder !== undefined) updates.sortOrder = sortOrder - - const [updatedFolder] = await db - .update(workflowFolder) - .set(updates) - .where(eq(workflowFolder.id, id)) - .returning() - - logger.info('Updated folder:', { id, updates }) + logger.info('Updated folder:', { id, updates: parsed.data.body }) - return NextResponse.json({ folder: updatedFolder }) + return NextResponse.json({ folder: result.folder }) } catch (error) { if (error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 73cd890eb84..a2e145bf1e3 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -150,7 +150,11 @@ describe('Folders API Route', () => { mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ orderBy: mockOrderBy }) + const defaultWhereResult = [] as Array> & { + orderBy: typeof mockOrderBy + } + defaultWhereResult.orderBy = mockOrderBy + mockWhere.mockReturnValue(defaultWhereResult) mockOrderBy.mockReturnValue(mockFolders) mockInsert.mockReturnValue({ values: mockValues }) @@ -328,6 +332,14 @@ describe('Folders API Route', () => { }, }) ) + mockWhere + .mockReturnValueOnce([{ minSortOrder: 5 }]) + .mockReturnValueOnce([{ minSortOrder: 2 }]) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) + mockReturning.mockReturnValueOnce([{ ...mockFolders[0], sortOrder: 1 }]) const req = createMockRequest('POST', { name: 'New Test Folder', @@ -355,6 +367,7 @@ describe('Folders API Route', () => { insertResult: [{ ...mockFolders[1] }], }) ) + mockReturning.mockReturnValueOnce([{ ...mockFolders[1] }]) const req = createMockRequest('POST', { name: 'Subfolder', @@ -478,8 +491,8 @@ describe('Folders API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockTransaction.mockImplementationOnce(() => { - throw new Error('Database transaction failed') + mockInsert.mockImplementationOnce(() => { + throw new Error('Database insert failed') }) const req = createMockRequest('POST', { @@ -493,7 +506,7 @@ describe('Folders API Route', () => { const data = await response.json() expect(data).toHaveProperty('error', 'Internal server error') - expect(mockLogger.error).toHaveBeenCalledWith('Error creating folder:', { + expect(mockLogger.error).toHaveBeenCalledWith('Failed to create workflow folder', { error: expect.any(Error), }) }) @@ -512,6 +525,10 @@ describe('Folders API Route', () => { }, }) ) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) const req = createMockRequest('POST', { name: ' Test Folder With Spaces ', @@ -538,6 +555,10 @@ describe('Folders API Route', () => { }, }) ) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) const req = createMockRequest('POST', { name: 'Test Folder', diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 3585a6b2834..404ebe0873c 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,19 +1,25 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm' +import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { performCreateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersAPI') +function folderMutationStatus(errorCode: string | undefined): number { + if (errorCode === 'validation') return 400 + if (errorCode === 'conflict') return 409 + if (errorCode === 'not_found') return 404 + return 500 +} + // GET - Fetch folders for a workspace export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -87,59 +93,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const id = clientId || generateId() - - const newFolder = await db.transaction(async (tx) => { - let sortOrder: number - if (providedSortOrder !== undefined) { - sortOrder = providedSortOrder - } else { - const folderParentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) - const workflowParentCondition = parentId - ? eq(workflow.folderId, parentId) - : isNull(workflow.folderId) - - const [[folderResult], [workflowResult]] = await Promise.all([ - tx - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), - tx - .select({ minSortOrder: min(workflow.sortOrder) }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)), - ]) - - const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce< - number | null - >((currentMin, candidate) => { - if (candidate == null) return currentMin - if (currentMin == null) return candidate - return Math.min(currentMin, candidate) - }, null) - - sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 - } - - const [folder] = await tx - .insert(workflowFolder) - .values({ - id, - name: name.trim(), - userId: session.user.id, - workspaceId, - parentId: parentId || null, - color: color || '#6B7280', - sortOrder, - }) - .returning() - - return folder + const result = await performCreateFolder({ + id: clientId, + userId: session.user.id, + workspaceId, + name, + parentId, + color, + sortOrder: providedSortOrder, }) - logger.info('Created new folder:', { id, name, workspaceId, parentId }) + if (!result.success || !result.folder) { + return NextResponse.json( + { error: result.error }, + { status: folderMutationStatus(result.errorCode) } + ) + } + + const newFolder = result.folder + + logger.info('Created new folder:', { id: newFolder.id, name, workspaceId, parentId }) captureServerEvent( session.user.id, @@ -148,26 +121,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { groups: { workspace: workspaceId } } ) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: id, - resourceName: name.trim(), - description: `Created folder "${name.trim()}"`, - metadata: { - name: name.trim(), - workspaceId, - parentId: parentId || undefined, - color: color || '#6B7280', - sortOrder: newFolder.sortOrder, - }, - request, - }) - return NextResponse.json({ folder: newFolder }) } catch (error) { logger.error('Error creating folder:', { error }) diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index 297dee54767..92929d23f11 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -1,15 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' +import { + getRestorableKnowledgeBase, + performRestoreKnowledgeBase, +} from '@/lib/knowledge/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreKnowledgeBaseAPI') @@ -27,16 +26,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const [kb] = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId: knowledgeBase.workspaceId, - userId: knowledgeBase.userId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, id)) - .limit(1) + const kb = await getRestorableKnowledgeBase(id) if (!kb) { return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) @@ -51,32 +41,21 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - await restoreKnowledgeBase(id, requestId) + const result = await performRestoreKnowledgeBase({ + knowledgeBaseId: id, + userId: auth.userId, + requestId, + }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } logger.info(`[${requestId}] Restored knowledge base ${id}`) - recordAudit({ - workspaceId: kb.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - metadata: { - knowledgeBaseName: kb.name, - }, - request, - }) - return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index b2b3b35f5b9..6795f6383e1 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -1,22 +1,15 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - McpDnsResolutionError, - McpDomainNotAllowedError, - McpSsrfError, - validateMcpDomain, - validateMcpServerSsrf, -} from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { performUpdateMcpServer } from '@/lib/mcp/orchestration' +import { + createMcpErrorResponse, + createMcpSuccessResponse, + mcpOrchestrationStatus, +} from '@/lib/mcp/utils' const logger = createLogger('McpServerAPI') @@ -55,100 +48,33 @@ export const PATCH = withRouteHandler( // Remove workspaceId from body to prevent it from being updated const { workspaceId: _, ...updateData } = body - if (updateData.url) { - try { - validateMcpDomain(updateData.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - try { - await validateMcpServerSsrf(updateData.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) - } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - } - - // Get the current server to check if URL is changing - const [currentServer] = await db - .select({ url: mcpServers.url }) - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - const [updatedServer] = await db - .update(mcpServers) - .set({ - ...updateData, - updatedAt: new Date(), - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .returning() - - if (!updatedServer) { + const result = await performUpdateMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + serverId, + name: updateData.name, + description: updateData.description, + transport: updateData.transport, + url: updateData.url, + headers: updateData.headers, + timeout: updateData.timeout, + retries: updateData.retries, + enabled: updateData.enabled, + request, + }) + if (!result.success || !result.server) { return createMcpErrorResponse( new Error('Server not found or access denied'), - 'Server not found', - 404 + result.error || 'Server not found', + mcpOrchestrationStatus(result.errorCode) ) } - - const shouldClearCache = - (body.url !== undefined && currentServer?.url !== body.url) || - body.enabled !== undefined || - body.headers !== undefined || - body.timeout !== undefined || - body.retries !== undefined - - if (shouldClearCache) { - await mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Cleared MCP cache after server lifecycle update`) - } + const updatedServer = result.server logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: updatedServer.name || serverId, - description: `Updated MCP server "${updatedServer.name || serverId}"`, - metadata: { - serverName: updatedServer.name, - transport: updatedServer.transport, - url: updatedServer.url, - updatedFields: Object.keys(updateData).filter( - (k) => k !== 'workspaceId' && k !== 'updatedAt' - ), - }, - request, - }) - return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) diff --git a/apps/sim/app/api/mcp/servers/route.test.ts b/apps/sim/app/api/mcp/servers/route.test.ts new file mode 100644 index 00000000000..e831c802e0a --- /dev/null +++ b/apps/sim/app/api/mcp/servers/route.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({ + mockPerformDeleteMcpServer: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(), + }, +})) + +vi.mock('@/lib/mcp/middleware', () => ({ + getParsedBody: () => undefined, + withMcpAuth: + () => + ( + handler: ( + request: NextRequest, + context: { + userId: string + userName: string + userEmail: string + workspaceId: string + requestId: string + } + ) => Promise + ) => + (request: NextRequest) => + handler(request, { + userId: 'user-1', + userName: 'Test User', + userEmail: 'test@example.com', + workspaceId: 'workspace-1', + requestId: 'request-1', + }), +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: vi.fn(), + performDeleteMcpServer: mockPerformDeleteMcpServer, +})) + +import { DELETE } from '@/app/api/mcp/servers/route' + +function createDeleteRequest(serverId = 'server-1') { + return new Request( + `http://localhost:3000/api/mcp/servers?workspaceId=workspace-1&serverId=${serverId}`, + { method: 'DELETE' } + ) as NextRequest +} + +describe('MCP servers DELETE route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 404 when orchestration reports a missing server', async () => { + mockPerformDeleteMcpServer.mockResolvedValueOnce({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + + const response = await DELETE(createDeleteRequest()) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toEqual({ success: false, error: 'Server not found' }) + }) + + it('returns 500 when orchestration reports an internal delete failure', async () => { + mockPerformDeleteMcpServer.mockResolvedValueOnce({ + success: false, + error: 'Failed to delete MCP server', + errorCode: 'internal', + }) + + const response = await DELETE(createDeleteRequest()) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toEqual({ success: false, error: 'Failed to delete MCP server' }) + }) +}) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index d2666431506..f0f2744b053 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -1,29 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp' import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - McpDnsResolutionError, - McpDomainNotAllowedError, - McpSsrfError, - validateMcpDomain, - validateMcpServerSsrf, -} from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' +import { performCreateMcpServer, performDeleteMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse, - generateMcpServerId, + mcpOrchestrationStatus, } from '@/lib/mcp/utils' -import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('McpServersAPI') @@ -82,142 +72,50 @@ export const POST = withRouteHandler( workspaceId, }) - try { - validateMcpDomain(body.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - try { - await validateMcpServerSsrf(body.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) - } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : generateId() - - const [existingServer] = await db - .select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt }) - .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) - - if (existingServer) { - logger.info( - `[${requestId}] Server with ID ${serverId} already exists, updating instead of creating` - ) - - await db - .update(mcpServers) - .set({ - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers || {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - updatedAt: new Date(), - deletedAt: null, - }) - .where(eq(mcpServers.id, serverId)) - - await mcpService.clearCache(workspaceId) - - logger.info( - `[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})` - ) - - return createMcpSuccessResponse({ serverId, updated: true }, 200) - } - - await db - .insert(mcpServers) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers || {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - await mcpService.clearCache(workspaceId) - - logger.info( - `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})` - ) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.mcpServerAdded({ - serverId, - serverName: body.name, - transport: body.transport, - workspaceId, - }) - } catch (_e) { - // Silently fail - } - const sourceParam = body.source as string | undefined const source = sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - captureServerEvent( - userId, - 'mcp_server_connected', - { workspace_id: workspaceId, server_name: body.name, transport: body.transport, source }, - { - groups: { workspace: workspaceId }, - setOnce: { first_mcp_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ + if (!body.url) { + return createMcpErrorResponse( + new Error('url is required'), + 'Missing required parameter', + 400 + ) + } + const result = await performCreateMcpServer({ workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name, - description: `Added MCP server "${body.name}"`, - metadata: { - serverName: body.name, - transport: body.transport, - url: body.url, - timeout: body.timeout || 30000, - retries: body.retries || 3, - source: source, - }, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + source, request, }) + if (!result.success || !result.serverId) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to register MCP server'), + result.error || 'Failed to register MCP server', + mcpOrchestrationStatus(result.errorCode) + ) + } - return createMcpSuccessResponse({ serverId }, 201) + logger.info( + `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` + ) + + return createMcpSuccessResponse( + result.updated + ? { serverId: result.serverId, updated: true } + : { serverId: result.serverId }, + result.updated ? 200 : 201 + ) } catch (error) { logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) @@ -256,48 +154,24 @@ export const DELETE = withRouteHandler( `[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}` ) - const [deletedServer] = await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .returning() - - if (!deletedServer) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } - - await mcpService.clearCache(workspaceId) - - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - - captureServerEvent( - userId, - 'mcp_server_disconnected', - { workspace_id: workspaceId, server_name: deletedServer.name, source }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ + const result = await performDeleteMcpServer({ workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId!, - resourceName: deletedServer.name, - description: `Removed MCP server "${deletedServer.name}"`, - metadata: { - serverName: deletedServer.name, - transport: deletedServer.transport, - url: deletedServer.url, - source, - }, + serverId, + source, request, }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to delete MCP server'), + result.error || 'Failed to delete MCP server', + mcpOrchestrationStatus(result.errorCode) + ) + } + + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 8836d712a74..95dc9f8adba 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -11,7 +10,10 @@ import { } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { + performDeleteWorkflowMcpServer, + performUpdateWorkflowMcpServer, +} from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('WorkflowMcpServerAPI') @@ -99,62 +101,30 @@ export const PATCH = withRouteHandler( logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) - const [existingServer] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + const result = await performUpdateWorkflowMcpServer({ + serverId, + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + isPublic: body.isPublic, + }) + if (!result.success || !result.server) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return createMcpErrorResponse( + new Error(result.error || 'Failed to update workflow MCP server'), + result.error || 'Failed to update workflow MCP server', + status ) - .limit(1) - - if (!existingServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) } - const updateData: Record = { - updatedAt: new Date(), - } - - if (body.name !== undefined) { - updateData.name = body.name.trim() - } - if (body.description !== undefined) { - updateData.description = body.description?.trim() || null - } - if (body.isPublic !== undefined) { - updateData.isPublic = body.isPublic - } - - const [updatedServer] = await db - .update(workflowMcpServer) - .set(updateData) - .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) - .returning() + const updatedServer = result.server logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: updatedServer.name, - description: `Updated workflow MCP server "${updatedServer.name}"`, - metadata: { - serverName: updatedServer.name, - isPublic: updatedServer.isPublic, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, - }) - return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { logger.error(`[${requestId}] Error updating workflow MCP server:`, error) @@ -179,34 +149,23 @@ export const DELETE = withRouteHandler( logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`) - const [deletedServer] = await db - .delete(workflowMcpServer) - .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) - ) - .returning() - - if (!deletedServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ + const result = await performDeleteWorkflowMcpServer({ + serverId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: deletedServer.name, - description: `Unpublished workflow MCP server "${deletedServer.name}"`, - metadata: { serverName: deletedServer.name }, - request, }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Server not found'), + result.error || 'Server not found', + result.errorCode === 'not_found' ? 404 : 500 + ) + } + const deletedServer = result.server + + logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 14eda122b3e..95e54946ded 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -11,9 +10,8 @@ import { } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { performDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' const logger = createLogger('WorkflowMcpToolAPI') @@ -99,80 +97,31 @@ export const PATCH = withRouteHandler( logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, toolId), - eq(workflowMcpTool.serverId, serverId), - isNull(workflowMcpTool.archivedAt) - ) + const result = await performUpdateWorkflowMcpTool({ + serverId, + toolId, + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + toolName: body.toolName, + toolDescription: body.toolDescription, + parameterSchema: body.parameterSchema, + }) + if (!result.success || !result.tool) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return createMcpErrorResponse( + new Error(result.error || 'Failed to update tool'), + result.error || 'Failed to update tool', + status ) - .limit(1) - - if (!existingTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - const updateData: Record = { - updatedAt: new Date(), } - if (body.toolName !== undefined) { - updateData.toolName = sanitizeToolName(body.toolName) - } - if (body.toolDescription !== undefined) { - updateData.toolDescription = body.toolDescription?.trim() || null - } - if (body.parameterSchema !== undefined) { - updateData.parameterSchema = body.parameterSchema - } - - const [updatedTool] = await db - .update(workflowMcpTool) - .set(updateData) - .where(eq(workflowMcpTool.id, toolId)) - .returning() + const updatedTool = result.tool logger.info(`[${requestId}] Successfully updated tool ${toolId}`) - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Updated tool "${updatedTool.toolName}" in MCP server`, - metadata: { - toolId, - toolName: updatedTool.toolName, - workflowId: updatedTool.workflowId, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, - }) - return createMcpSuccessResponse({ tool: updatedTool }) } catch (error) { logger.error(`[${requestId}] Error updating tool:`, error) @@ -197,47 +146,24 @@ export const DELETE = withRouteHandler( logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [deletedTool] = await db - .delete(workflowMcpTool) - .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) - .returning() - - if (!deletedTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ + const result = await performDeleteWorkflowMcpTool({ + serverId, + toolId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Removed tool "${deletedTool.toolName}" from MCP server`, - metadata: { toolId, toolName: deletedTool.toolName, workflowId: deletedTool.workflowId }, - request, }) + if (!result.success || !result.tool) { + return createMcpErrorResponse( + new Error(result.error || 'Tool not found'), + result.error || 'Tool not found', + result.errorCode === 'not_found' ? 404 : 500 + ) + } + const deletedTool = result.tool + + logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) } catch (error) { diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index cc000883893..85e37371d57 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -1,9 +1,7 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { @@ -12,11 +10,8 @@ import { } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { performCreateWorkflowMcpTool } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' const logger = createLogger('WorkflowMcpToolsAPI') @@ -110,134 +105,33 @@ export const POST = withRouteHandler( workflowId: body.workflowId, }) - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [workflowRecord] = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(and(eq(workflow.id, body.workflowId), isNull(workflow.archivedAt))) - .limit(1) - - if (!workflowRecord) { - return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404) - } - - if (workflowRecord.workspaceId !== workspaceId) { - return createMcpErrorResponse( - new Error('Workflow does not belong to this workspace'), - 'Access denied', - 403 - ) - } - - if (!workflowRecord.isDeployed) { - return createMcpErrorResponse( - new Error('Workflow must be deployed before adding as a tool'), - 'Workflow not deployed', - 400 - ) - } - - const hasStartBlock = await hasValidStartBlock(body.workflowId) - if (!hasStartBlock) { - return createMcpErrorResponse( - new Error('Workflow must have a Start block to be used as an MCP tool'), - 'No start block found', - 400 - ) - } - - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, serverId), - eq(workflowMcpTool.workflowId, body.workflowId), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) - - if (existingTool) { + const result = await performCreateWorkflowMcpTool({ + serverId, + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + workflowId: body.workflowId, + toolName: body.toolName, + toolDescription: body.toolDescription, + parameterSchema: body.parameterSchema, + }) + if (!result.success || !result.tool) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 400 return createMcpErrorResponse( - new Error('This workflow is already added as a tool to this server'), - 'Tool already exists', - 409 + new Error(result.error || 'Failed to add tool'), + result.error || 'Failed to add tool', + result.errorCode === 'internal' ? 500 : status ) } - const toolName = sanitizeToolName(body.toolName?.trim() || workflowRecord.name) - const toolDescription = - body.toolDescription?.trim() || - workflowRecord.description || - `Execute ${workflowRecord.name} workflow` - - const parameterSchema = - body.parameterSchema && Object.keys(body.parameterSchema).length > 0 - ? body.parameterSchema - : await generateParameterSchemaForWorkflow(body.workflowId) - - const toolId = generateId() - const [tool] = await db - .insert(workflowMcpTool) - .values({ - id: toolId, - serverId, - workflowId: body.workflowId, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() + const tool = result.tool logger.info( - `[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}` + `[${requestId}] Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}` ) - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Added tool "${toolName}" to MCP server`, - metadata: { - toolId, - toolName, - toolDescription, - workflowId: body.workflowId, - workflowName: workflowRecord.name, - }, - request, - }) - return createMcpSuccessResponse({ tool }, 201) } catch (error) { logger.error(`[${requestId}] Error adding tool:`, error) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 49efe49f2a3..a0bb67c6b28 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -1,19 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createWorkflowMcpServerBodySchema } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { performCreateWorkflowMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' const logger = createLogger('WorkflowMcpServersAPI') @@ -112,111 +107,31 @@ export const POST = withRouteHandler( workflowIds: body.workflowIds, }) - const serverId = generateId() - - const [server] = await db - .insert(workflowMcpServer) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name.trim(), - description: body.description?.trim() || null, - isPublic: body.isPublic ?? false, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - const workflowIds: string[] = body.workflowIds || [] - const addedTools: Array<{ workflowId: string; toolName: string }> = [] - - if (workflowIds.length > 0) { - const workflows = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) - - for (const workflowRecord of workflows) { - if (workflowRecord.workspaceId !== workspaceId) { - logger.warn( - `[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace` - ) - continue - } - - if (!workflowRecord.isDeployed) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`) - continue - } - - const hasStartBlock = await hasValidStartBlock(workflowRecord.id) - if (!hasStartBlock) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`) - continue - } - - const toolName = sanitizeToolName(workflowRecord.name) - const toolDescription = - workflowRecord.description || `Execute ${workflowRecord.name} workflow` - - const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) - - const toolId = generateId() - await db.insert(workflowMcpTool).values({ - id: toolId, - serverId, - workflowId: workflowRecord.id, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), - }) - - addedTools.push({ workflowId: workflowRecord.id, toolName }) - } - - logger.info( - `[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`, - addedTools.map((t) => t.toolName) + const result = await performCreateWorkflowMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + isPublic: body.isPublic, + workflowIds: body.workflowIds, + }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to create workflow MCP server'), + result.error || 'Failed to create workflow MCP server', + result.errorCode === 'validation' ? 400 : 500 ) - - if (addedTools.length > 0) { - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - } } + const { server } = result + const addedTools = result.addedTools || [] + logger.info( - `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` + `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` ) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name.trim(), - description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`, - metadata: { - serverName: body.name.trim(), - isPublic: body.isPublic ?? false, - toolCount: addedTools.length, - toolNames: addedTools.map((t) => t.toolName), - workflowIds: addedTools.map((t) => t.workflowId), - }, - request, - }) - return createMcpSuccessResponse({ server, addedTools }, 201) } catch (error) { logger.error(`[${requestId}] Error creating workflow MCP server:`, error) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index bc80d9cec56..129ec4c1582 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -15,6 +15,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteJob, performUpdateJob } from '@/lib/workflows/schedules/orchestration' import { validateCronExpression } from '@/lib/workflows/schedules/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -168,58 +169,32 @@ export const PUT = withRouteHandler( ) } - const updates = validatedBody - const setFields: Record = { updatedAt: new Date() } - - if (updates.title !== undefined) setFields.jobTitle = updates.title.trim() - if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim() - if (updates.timezone !== undefined) setFields.timezone = updates.timezone - if (updates.lifecycle !== undefined) { - setFields.lifecycle = updates.lifecycle - if (updates.lifecycle === 'persistent') { - setFields.maxRuns = null - } + if (!workspaceId) { + return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 }) } - if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns - - if (updates.cronExpression !== undefined) { - const tz = updates.timezone ?? schedule.timezone ?? 'UTC' - const cronResult = validateCronExpression(updates.cronExpression, tz) - if (!cronResult.isValid) { - return NextResponse.json( - { error: cronResult.error || 'Invalid cron expression' }, - { status: 400 } - ) - } - setFields.cronExpression = updates.cronExpression - if (schedule.status === 'active' && cronResult.nextRun) { - setFields.nextRunAt = cronResult.nextRun - } - } - - await db - .update(workflowSchedule) - .set(setFields) - .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) - - logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) - recordAudit({ + const updateResult = await performUpdateJob({ + jobId: scheduleId, workspaceId, - actorId: session.user.id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.SCHEDULE_UPDATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: scheduleId, - resourceName: schedule.jobTitle ?? undefined, - description: `Updated job schedule "${schedule.jobTitle ?? scheduleId}"`, - metadata: { - operation: 'update', - updatedFields: Object.keys(setFields).filter((k) => k !== 'updatedAt'), - }, + title: validatedBody.title, + prompt: validatedBody.prompt, + timezone: validatedBody.timezone, + lifecycle: validatedBody.lifecycle, + maxRuns: validatedBody.maxRuns, + cronExpression: validatedBody.cronExpression, request, }) + if (!updateResult.success) { + return NextResponse.json( + { error: updateResult.error || 'Failed to update schedule' }, + { status: updateResult.errorCode === 'validation' ? 400 : 500 } + ) + } + + logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) return NextResponse.json({ message: 'Schedule updated successfully' }) } @@ -298,6 +273,27 @@ export const DELETE = withRouteHandler( if (result instanceof NextResponse) return result const { schedule, workspaceId } = result + if (schedule.sourceType === 'job') { + if (!workspaceId) { + return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 }) + } + const deleteResult = await performDeleteJob({ + jobId: scheduleId, + workspaceId, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + if (!deleteResult.success) { + return NextResponse.json( + { error: deleteResult.error || 'Failed to delete schedule' }, + { status: deleteResult.errorCode === 'not_found' ? 404 : 500 } + ) + } + return NextResponse.json({ message: 'Schedule deleted successfully' }) + } + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 574166ee4c3..37bdc00ab24 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -1,8 +1,6 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -11,8 +9,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { validateCronExpression } from '@/lib/workflows/schedules/utils' +import { performCreateJob } from '@/lib/workflows/schedules/orchestration' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('ScheduledAPI') @@ -228,80 +225,43 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - const validation = validateCronExpression(cronExpression, timezone) - if (!validation.isValid) { - return NextResponse.json( - { error: validation.error || 'Invalid cron expression' }, - { status: 400 } - ) - } - - let nextRunAt = validation.nextRun! - if (startDate) { - const start = new Date(startDate) - if (start > new Date()) { - nextRunAt = start - } - } - - const now = new Date() - const id = generateId() - - await db.insert(workflowSchedule).values({ - id, + const result = await performCreateJob({ + workspaceId, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + title, + prompt, cronExpression, - triggerType: 'schedule', - sourceType: 'job', - status: 'active', timezone, - nextRunAt, - createdAt: now, - updatedAt: now, - failedCount: 0, - jobTitle: title.trim(), - prompt: prompt.trim(), lifecycle, - maxRuns: maxRuns ?? null, - runCount: 0, - sourceWorkspaceId: workspaceId, - sourceUserId: session.user.id, + maxRuns, + startDate, + request: req, }) + if (!result.success || !result.schedule) { + return NextResponse.json( + { error: result.error || 'Failed to create schedule' }, + { status: result.errorCode === 'validation' ? 400 : 500 } + ) + } - logger.info(`[${requestId}] Created job schedule ${id}`, { + logger.info(`[${requestId}] Created job schedule ${result.schedule.id}`, { title, cronExpression, timezone, lifecycle, }) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.SCHEDULE_CREATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: id, - resourceName: title.trim(), - description: `Created job schedule "${title.trim()}"`, - metadata: { - cronExpression, - timezone, - lifecycle, - maxRuns: maxRuns ?? null, - }, - request: req, - }) - - captureServerEvent( - session.user.id, - 'scheduled_task_created', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json( - { schedule: { id, status: 'active', cronExpression, nextRunAt } }, + { + schedule: { + id: result.schedule.id, + status: result.schedule.status, + cronExpression: result.schedule.cronExpression, + nextRunAt: result.schedule.nextRunAt, + }, + }, { status: 201 } ) } catch (error) { diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index c6955984fed..e7dd16ff85b 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -1,11 +1,11 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { tableIdParamsSchema } from '@/lib/api/contracts/tables' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getTableById, restoreTable, TableConflictError } from '@/lib/table' +import { getTableById } from '@/lib/table' +import { performRestoreTable } from '@/lib/table/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreTableAPI') @@ -31,33 +31,20 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await restoreTable(tableId, requestId) + const result = await performRestoreTable({ tableId, userId: auth.userId, requestId }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } logger.info(`[${requestId}] Restored table ${tableId}`) - recordAudit({ - workspaceId: table.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.TABLE_RESTORED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Restored table "${table.name}"`, - metadata: { - tableName: table.name, - workspaceId: table.workspaceId, - }, - request, + return NextResponse.json({ + success: true, + data: { table: result.table }, }) - - return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error restoring table ${tableId}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 70900716520..89a48b80896 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -182,33 +182,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`) + const responseTables = tables.map((t) => { + const schemaData = t.schema as TableSchema + return { + id: t.id, + name: t.name, + description: t.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: t.rowCount, + maxRows: t.maxRows, + workspaceId: t.workspaceId, + createdBy: t.createdBy, + createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), + updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), + archivedAt: + t.archivedAt instanceof Date + ? t.archivedAt.toISOString() + : t.archivedAt + ? String(t.archivedAt) + : null, + } + }) + return NextResponse.json({ success: true, data: { - tables: tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), - }, - rowCount: t.rowCount, - maxRows: t.maxRows, - createdBy: t.createdBy, - createdAt: - t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: - t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - archivedAt: - t.archivedAt instanceof Date - ? t.archivedAt.toISOString() - : t.archivedAt - ? String(t.archivedAt) - : null, - } - }), + tables: responseTables, totalCount: tables.length, }, }) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 65c129787b1..cfb53f06430 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -3,17 +3,20 @@ import { type NextRequest, NextResponse } from 'next/server' import { fileManageContract } from '@/lib/api/contracts/tools/file' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchWorkspaceFileBuffer, getWorkspaceFile, - getWorkspaceFileByName, + resolveWorkspaceFileReference, updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -143,11 +146,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const selectedFileId = fileId || (fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput) - ? typeof fileInput.id === 'string' - ? fileInput.id - : typeof fileInput.fileId === 'string' - ? fileInput.fileId - : '' + ? (() => { + const obj = fileInput as Record + return typeof obj.id === 'string' + ? obj.id + : typeof obj.fileId === 'string' + ? obj.fileId + : '' + })() : '') if (!selectedFileId) { @@ -222,14 +228,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'write': { const { fileName, content, contentType } = body - const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) + const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments: folderSegments, + }) + const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName)) const fileBuffer = Buffer.from(content ?? '', 'utf-8') const result = await uploadWorkspaceFile( workspaceId, userId, fileBuffer, - fileName, - mimeType + leafName, + mimeType, + { folderId } ) logger.info('File created', { @@ -249,10 +262,50 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } + case 'move': { + const { fileId, targetFolder } = body + const pathSegments = targetFolder.trim() + ? targetFolder + .trim() + .split('/') + .map((s) => s.trim()) + .filter(Boolean) + : [] + const targetFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments, + }) + const moveResult = await performMoveWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + targetFolderId, + }) + if (!moveResult.success) { + return NextResponse.json( + { success: false, error: moveResult.error }, + { + status: + moveResult.errorCode === 'conflict' + ? 409 + : moveResult.errorCode === 'not_found' + ? 404 + : 400, + } + ) + } + logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' }) + return NextResponse.json({ + success: true, + data: { fileId, targetFolder: targetFolder || '(root)' }, + }) + } + case 'append': { const { fileName, content } = body - const existing = await getWorkspaceFileByName(workspaceId, fileName) + const existing = await resolveWorkspaceFileReference(workspaceId, fileName) if (!existing) { return NextResponse.json( { success: false, error: `File not found: "${fileName}"` }, diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index a2e4c029d1d..5482a4d378f 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -1,15 +1,11 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files' import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - deleteWorkspaceFile, - fetchWorkspaceFileBuffer, - getWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { checkRateLimit, createRateLimitResponse, @@ -97,24 +93,19 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - await deleteWorkspaceFile(workspaceId, fileId) + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + }) + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 500 }) + } logger.info( `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` ) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileRecord.name, - description: `Archived file "${fileRecord.name}" via API`, - metadata: { fileSize: fileRecord.size, fileType: fileRecord.type }, - request, - }) - return NextResponse.json({ success: true, data: { diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index 8c6b7a928ea..7d20bf3e3eb 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' @@ -8,7 +7,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { restoreWorkflow } from '@/lib/workflows/lifecycle' +import { performRestoreWorkflow } from '@/lib/workflows/orchestration' import { getWorkflowById } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -50,31 +49,20 @@ export const POST = withRouteHandler( } await assertFolderMutable(workflowData.folderId) - const result = await restoreWorkflow(workflowId, { requestId }) + const result = await performRestoreWorkflow({ + workflowId, + userId: auth.userId, + requestId, + }) - if (!result.restored) { - return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) } logger.info(`[${requestId}] Restored workflow ${workflowId}`) - recordAudit({ - workspaceId: workflowData.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_RESTORED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: workflowData.name, - description: `Restored workflow "${workflowData.name}"`, - metadata: { - workflowName: workflowData.name, - workspaceId: workflowData.workspaceId || undefined, - }, - request, - }) - captureServerEvent( auth.userId, 'workflow_restored', diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 68a0ae3e57d..d752a3e6dc5 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -27,6 +27,7 @@ const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById const mockAuthorizeWorkflowByWorkspacePermission = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow +const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({ mockDbUpdate: vi.fn(), @@ -85,6 +86,22 @@ describe('Workflow By ID API Route', () => { }) mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) + mockPerformUpdateWorkflow.mockImplementation(async (params) => ({ + success: true, + workflow: { + id: params.workflowId, + name: params.name ?? params.currentName, + description: params.description ?? null, + color: params.color ?? null, + workspaceId: params.workspaceId, + folderId: params.folderId ?? params.currentFolderId ?? null, + sortOrder: params.sortOrder ?? null, + locked: params.locked ?? null, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }, + })) mockDbTransaction.mockImplementation(async (callback) => callback({ execute: vi.fn().mockResolvedValue(undefined), @@ -595,8 +612,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "Duplicate Name" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -628,8 +648,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "Duplicate Name" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -748,9 +771,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - // Duplicate exists in target folder - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "My Workflow" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 20c9c896ba5..2f3f0ebe3ce 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -8,7 +8,7 @@ import { FolderLockedError, WorkflowLockedError, } from '@sim/workflow-authz' -import { and, eq, isNull, ne, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -16,7 +16,7 @@ import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/aut import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkflow } from '@/lib/workflows/orchestration' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { getWorkflowById } from '@/lib/workflows/utils' @@ -338,78 +338,33 @@ export const PUT = withRouteHandler( await assertFolderMutable(updates.folderId) } - const updateData: Record = { updatedAt: new Date() } - if (updates.name !== undefined) updateData.name = updates.name - if (updates.description !== undefined) updateData.description = updates.description - if (updates.color !== undefined) updateData.color = updates.color - if (updates.folderId !== undefined) updateData.folderId = updates.folderId - if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder - if (updates.locked !== undefined) updateData.locked = updates.locked - - if (updates.name !== undefined || updates.folderId !== undefined) { - const targetName = updates.name ?? workflowData.name - const targetFolderId = - updates.folderId !== undefined ? updates.folderId : workflowData.folderId - - if (!workflowData.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - - const conditions = [ - eq(workflow.workspaceId, workflowData.workspaceId), - isNull(workflow.archivedAt), - eq(workflow.name, targetName), - ne(workflow.id, workflowId), - ] - - if (targetFolderId) { - conditions.push(eq(workflow.folderId, targetFolderId)) - } else { - conditions.push(isNull(workflow.folderId)) - } + if (!workflowData.workspaceId) { + logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } - const [duplicate] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(...conditions)) - .limit(1) + const result = await performUpdateWorkflow({ + workflowId, + userId, + workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + ...updates, + requestId, + }) - if (duplicate) { - logger.warn( - `[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}` - ) - return NextResponse.json( - { error: `A workflow named "${targetName}" already exists in this folder` }, - { status: 409 } - ) - } + if (!result.success || !result.workflow) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - color: workflow.color, - workspaceId: workflow.workspaceId, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder, - locked: workflow.locked, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, - archivedAt: workflow.archivedAt, - }) - const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates: updateData, + updates, }) - return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) + return NextResponse.json({ workflow: result.workflow }, { status: 200 }) } catch (error: any) { if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index ed10d8dc497..ebb1e11a1d6 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -87,9 +87,9 @@ describe('Workflows API Route - POST ordering', () => { it('uses top insertion against mixed siblings (folders + workflows)', async () => { const minResultsQueue: Array> = [ + [], [{ minOrder: 5 }], [{ minOrder: 2 }], - [], ] mockDbSelect.mockImplementation(() => ({ diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 17024877312..4a80994510e 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,9 +1,7 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, workflow, workflowFolder } from '@sim/db/schema' +import { permissions, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -11,9 +9,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -183,86 +179,31 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - const workflowId = clientId || generateId() - const now = new Date() - - logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`) - - let sortOrder: number - if (providedSortOrder !== undefined) { - sortOrder = providedSortOrder - } else { - const workflowParentCondition = folderId - ? eq(workflow.folderId, folderId) - : isNull(workflow.folderId) - const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) - - const [[workflowMinResult], [folderMinResult]] = await Promise.all([ - db - .select({ minOrder: min(workflow.sortOrder) }) - .from(workflow) - .where( - and( - eq(workflow.workspaceId, workspaceId), - workflowParentCondition, - isNull(workflow.archivedAt) - ) - ), - db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), - ]) - - const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< - number | null - >((currentMin, candidate) => { - if (candidate == null) return currentMin - if (currentMin == null) return candidate - return Math.min(currentMin, candidate) - }, null) + const result = await performCreateWorkflow({ + id: clientId, + name: requestedName, + description, + color, + workspaceId, + folderId, + sortOrder: providedSortOrder, + deduplicate, + userId, + requestId, + }) - sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 + if (!result.success || !result.workflow) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } - let name = requestedName - - if (deduplicate) { - name = await deduplicateWorkflowName(requestedName, workspaceId, folderId) - } else { - const duplicateConditions = [ - eq(workflow.workspaceId, workspaceId), - isNull(workflow.archivedAt), - eq(workflow.name, requestedName), - ] - - if (folderId) { - duplicateConditions.push(eq(workflow.folderId, folderId)) - } else { - duplicateConditions.push(isNull(workflow.folderId)) - } - - const [duplicateWorkflow] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(...duplicateConditions)) - .limit(1) - - if (duplicateWorkflow) { - return NextResponse.json( - { error: `A workflow named "${requestedName}" already exists in this folder` }, - { status: 409 } - ) - } - } + const createdWorkflow = result.workflow import('@/lib/core/telemetry') .then(({ PlatformEvents }) => { PlatformEvents.workflowCreated({ - workflowId, - name, + workflowId: createdWorkflow.id, + name: createdWorkflow.name, workspaceId: workspaceId || undefined, folderId: folderId || undefined, }) @@ -271,74 +212,36 @@ export const POST = withRouteHandler(async (req: NextRequest) => { // Silently fail }) - const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() - - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId, - workspaceId, - folderId: folderId || null, - sortOrder, - name, - description, - color, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) - - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) - }) - - logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`) + logger.info( + `[${requestId}] Successfully created workflow ${createdWorkflow.id} with default blocks` + ) captureServerEvent( userId, 'workflow_created', - { workflow_id: workflowId, workspace_id: workspaceId ?? '', name }, + { + workflow_id: createdWorkflow.id, + workspace_id: workspaceId ?? '', + name: createdWorkflow.name, + }, { groups: workspaceId ? { workspace: workspaceId } : undefined, setOnce: { first_workflow_created_at: new Date().toISOString() }, } ) - recordAudit({ - workspaceId, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_CREATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: name, - description: `Created workflow "${name}"`, - metadata: { - name, - description: description || undefined, - color, - workspaceId, - folderId: folderId || undefined, - sortOrder, - }, - request: req, - }) - return NextResponse.json({ - id: workflowId, - name, - description, - color, - workspaceId, - folderId, - sortOrder, - createdAt: now, - updatedAt: now, - startBlockId, - subBlockValues, + id: createdWorkflow.id, + name: createdWorkflow.name, + description: createdWorkflow.description, + color: createdWorkflow.color, + workspaceId: createdWorkflow.workspaceId, + folderId: createdWorkflow.folderId, + sortOrder: createdWorkflow.sortOrder, + createdAt: createdWorkflow.createdAt, + updatedAt: createdWorkflow.updatedAt, + startBlockId: createdWorkflow.startBlockId, + subBlockValues: createdWorkflow.subBlockValues, }) } catch (error) { logger.error(`[${requestId}] Error creating workflow`, error) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index 0d9caeec189..6242eb64a01 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,8 +9,8 @@ import { deleteWorkspaceApiKeysContract, } from '@/lib/api/contracts/api-keys' import { parseRequest } from '@/lib/api/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -106,60 +105,17 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { name, source } = parsed.data.body - const existingKey = await db - .select() - .from(apiKey) - .where( - and( - eq(apiKey.workspaceId, workspaceId), - eq(apiKey.name, name), - eq(apiKey.type, 'workspace') - ) - ) - .limit(1) - - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A workspace API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - workspaceId, - userId: userId, - createdBy: userId, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'workspace', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - try { - PlatformEvents.apiKeyGenerated({ - userId: userId, - keyName: name, - }) - } catch { - // Telemetry should not fail the operation + const result = await performCreateWorkspaceApiKey({ + workspaceId, + userId, + name, + source, + actorName: session.user.name, + actorEmail: session.user.email, + }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } captureServerEvent( @@ -174,25 +130,8 @@ export const POST = withRouteHandler( logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - resourceName: name, - description: `Created API key "${name}"`, - metadata: { keyName: name, keyType: 'workspace', source: source ?? 'settings' }, - request, - }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, + key: result.key, }) } catch (error: unknown) { logger.error(`[${requestId}] Workspace API key POST error`, error) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index fe6ef946bc1..fe225810380 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' @@ -6,7 +5,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileConflictError, restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkspaceFileAPI') @@ -38,28 +37,22 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await restoreWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Restored workspace file ${fileId}`) - - recordAudit({ + const result = await performRestoreWorkspaceFile({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_RESTORED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileId, - description: `Restored workspace file ${fileId}`, - request, + fileId, + userId: session.user.id, }) + if (!result.success) { + return NextResponse.json( + { error: result.error }, + { status: result.errorCode === 'conflict' ? 409 : 500 } + ) + } + + logger.info(`[${requestId}] Restored workspace file ${fileId}`) return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof FileConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index dea5882bc0d..cba4dd8a72f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -9,11 +8,11 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { - deleteWorkspaceFile, - FileConflictError, - renameWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' + performDeleteWorkspaceFileItems, + performRenameWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -51,26 +50,30 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const updatedFile = await renameWorkspaceFile(workspaceId, fileId, name) - - logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`) - - recordAudit({ + const result = await performRenameWorkspaceFile({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: updatedFile.name, - description: `Renamed file to "${updatedFile.name}"`, - request, + fileId, + name, + userId: session.user.id, }) + if (!result.success || !result.file) { + return NextResponse.json( + { success: false, error: result.error }, + { status: result.errorCode === 'conflict' ? 409 : 500 } + ) + } + + logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`) + captureServerEvent( + session.user.id, + 'file_renamed', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) return NextResponse.json({ success: true, - file: updatedFile, + file: result.file, }) } catch (error) { logger.error(`[${requestId}] Error renaming workspace file:`, error) @@ -79,7 +82,7 @@ export const PATCH = withRouteHandler( success: false, error: error instanceof Error ? error.message : 'Failed to rename file', }, - { status: error instanceof FileConflictError ? 409 : 500 } + { status: 500 } ) } } @@ -120,22 +123,33 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await deleteWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Archived workspace file: ${fileId}`) - - recordAudit({ + const result = await performDeleteWorkspaceFileItems({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - description: `Archived file "${fileId}"`, - request, + userId: session.user.id, + fileIds: [fileId], }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + logger.info(`[${requestId}] Archived workspace file: ${fileId}`) + + captureServerEvent( + session.user.id, + 'file_deleted', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) return NextResponse.json({ success: true, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts new file mode 100644 index 00000000000..1f47f650bd6 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileBulkArchiveAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + fileIds, + folderIds, + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + if (!result.deletedItems) { + return NextResponse.json( + { success: false, error: 'Failed to delete workspace file items' }, + { status: 500 } + ) + } + + captureServerEvent( + session.user.id, + 'file_bulk_deleted', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, deletedItems: result.deletedItems }) + } catch (error) { + logger.error('Failed to bulk archive workspace file items:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts new file mode 100644 index 00000000000..c65b5438158 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import JSZip from 'jszip' +import { type NextRequest, NextResponse } from 'next/server' +import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + buildWorkspaceFileFolderPathMap, + fetchWorkspaceFileBuffer, + listWorkspaceFileFolders, + listWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +const logger = createLogger('WorkspaceFilesDownloadAPI') +const MAX_ZIP_DOWNLOAD_FILES = 100 +const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 + +function safeZipPath(path: string): string { + return path + .split('/') + .map((segment) => { + const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_') + return cleaned === '.' || cleaned === '..' ? '_' : cleaned + }) + .filter(Boolean) + .join('/') +} + +function withZipPathSuffix(path: string, suffix: number): string { + const slashIndex = path.lastIndexOf('/') + const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : '' + const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path + const dotIndex = filename.lastIndexOf('.') + + return dotIndex > 0 + ? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}` + : `${directory}${filename} (${suffix})` +} + +function collectDescendantFolderIds( + selectedFolderIds: string[], + folders: Array<{ id: string; parentId: string | null }> +): Set { + const folderIds = new Set(selectedFolderIds) + let changed = true + while (changed) { + changed = false + for (const folder of folders) { + if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { + folderIds.add(folder.id) + changed = true + } + } + } + return folderIds +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds } = parsed.data.query + + const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const [files, folders] = await Promise.all([ + listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }), + listWorkspaceFileFolders(workspaceId), + ]) + const folderPaths = buildWorkspaceFileFolderPathMap(folders) + const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const requestedFileIds = new Set(fileIds) + const filesToZip = files.filter( + (file) => + requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId)) + ) + + if (filesToZip.length === 0) { + return NextResponse.json({ error: 'No files selected for download' }, { status: 400 }) + } + + if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { + return NextResponse.json( + { + error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`, + }, + { status: 400 } + ) + } + + const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) + if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) { + return NextResponse.json( + { + error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, + }, + { status: 400 } + ) + } + + const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file))) + + // Assemble zip synchronously so path deduplication is deterministic. + const zip = new JSZip() + const usedPaths = new Set() + for (let i = 0; i < filesToZip.length; i++) { + const file = filesToZip[i] + const buffer = buffers[i] + const folderPath = file.folderId ? folderPaths.get(file.folderId) : null + const basePath = + safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) || + safeZipPath(file.name) || + file.id + let zipPath = basePath + let suffix = 2 + while (usedPaths.has(zipPath)) { + zipPath = withZipPathSuffix(basePath, suffix) + suffix++ + } + usedPaths.add(zipPath) + zip.file(zipPath, buffer) + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) + return new NextResponse(new Uint8Array(zipBuffer), { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="workspace-files.zip"', + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + logger.error('Failed to download workspace file selection:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..86df6e83f91 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,66 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + performRestoreWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFolderRestoreAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performRestoreWorkspaceFileFolder({ + workspaceId, + folderId, + userId: session.user.id, + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + const { folder, restoredItems } = result + if (!folder || !restoredItems) { + return NextResponse.json( + { success: false, error: 'Failed to restore workspace file folder' }, + { status: 500 } + ) + } + + logger.info(`Restored workspace file folder: ${folderId}`) + + captureServerEvent( + session.user.id, + 'folder_restored', + { folder_id: folderId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder, restoredItems }) + } catch (error) { + logger.error('Failed to restore workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..78232e8a704 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -0,0 +1,121 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + deleteWorkspaceFileFolderContract, + updateWorkspaceFileFolderContract, +} from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + performDeleteWorkspaceFileItems, + performUpdateWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFolderAPI') + +async function assertWritePermission(userId: string, workspaceId: string) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + return permission === 'admin' || permission === 'write' +} + +export const PATCH = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + if (!(await assertWritePermission(session.user.id, workspaceId))) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performUpdateWorkspaceFileFolder({ + workspaceId, + folderId, + userId: session.user.id, + ...parsed.data.body, + }) + if (!result.success || !result.folder) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + captureServerEvent( + session.user.id, + 'folder_renamed', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder: result.folder }) + } catch (error) { + logger.error('Failed to update workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + if (!(await assertWritePermission(session.user.id, workspaceId))) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + folderIds: [folderId], + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + if (!result.deletedItems) { + return NextResponse.json( + { success: false, error: 'Failed to delete workspace file folder' }, + { status: 500 } + ) + } + + captureServerEvent( + session.user.id, + 'folder_deleted', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, deletedItems: result.deletedItems }) + } catch (error) { + logger.error('Failed to delete workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts new file mode 100644 index 00000000000..02de14dcf1e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + createWorkspaceFileFolderContract, + listWorkspaceFileFoldersContract, +} from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' +import { + performCreateWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFoldersAPI') + +async function getWorkspacePermission(userId: string, workspaceId: string) { + return getUserEntityPermissions(userId, 'workspace', workspaceId) +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { scope } = parsed.data.query + + const permission = await getWorkspacePermission(session.user.id, workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const folders = await listWorkspaceFileFolders(workspaceId, { scope }) + return NextResponse.json({ success: true, folders }) + } +) + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { name, parentId } = parsed.data.body + + const permission = await getWorkspacePermission(session.user.id, workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performCreateWorkspaceFileFolder({ + workspaceId, + userId: session.user.id, + name, + parentId, + }) + if (!result.success || !result.folder) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + captureServerEvent( + session.user.id, + 'folder_created', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder: result.folder }) + } catch (error) { + logger.error('Failed to create workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts new file mode 100644 index 00000000000..81861789eee --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileMoveAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds, targetFolderId } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performMoveWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + fileIds, + folderIds, + targetFolderId, + }) + if (!result.success || !result.movedItems) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'validation' + ? 400 + : 500, + } + ) + } + if (fileIds.length > 0) { + captureServerEvent( + session.user.id, + 'file_moved', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + } + if (folderIds.length > 0) { + captureServerEvent( + session.user.id, + 'folder_moved', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + } + return NextResponse.json({ + success: true, + movedItems: result.movedItems, + }) + } catch (error) { + logger.error('Failed to move workspace file items:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts index 332a9386ca7..1227f93c504 100644 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { checkStorageQuota } from '@/lib/billing/storage' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' @@ -31,7 +32,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { params, body } = parsed.data const workspaceId = params.id - const { fileName, contentType, fileSize } = body + const { fileName, contentType, fileSize, folderId } = body const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'admin' && permission !== 'write') { @@ -46,6 +47,16 @@ export const POST = withRouteHandler( ) } + let targetFolderId: string | null + try { + targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Invalid target folder' }, + { status: 400 } + ) + } + if (!hasCloudStorage()) { logger.info(`Local storage detected, signaling API fallback for ${fileName}`) return NextResponse.json({ @@ -73,7 +84,7 @@ export const POST = withRouteHandler( userId, customKey: key, expirationSeconds: 3600, - metadata: { workspaceId }, + metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) }, }) const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace` diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts index dfcaa537b5e..0b6d4876ab3 100644 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/register/route.ts @@ -33,7 +33,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { params, body } = parsed.data const workspaceId = params.id - const { key, name, contentType } = body + const { key, name, contentType, folderId } = body const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'admin' && permission !== 'write') { @@ -56,6 +56,7 @@ export const POST = withRouteHandler( key, originalName: name, contentType, + folderId, }) if (created) { diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index d89b12118e8..d8d4c2e691c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -123,6 +123,9 @@ export const POST = withRouteHandler( const formData = await request.formData() const rawFile = formData.get('file') + const rawFolderId = formData.get('folderId') + const folderId = + typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null if (!rawFile || !(rawFile instanceof File)) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }) @@ -146,7 +149,8 @@ export const POST = withRouteHandler( session.user.id, buffer, fileName, - rawFile.type || 'application/octet-stream' + rawFile.type || 'application/octet-stream', + { folderId } ) logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index d609462d9a1..716bb0e85d8 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -41,6 +41,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, ModalTabs, @@ -859,7 +860,9 @@ export default function PlaygroundPage() { Modal {size.toUpperCase()} -

This is a {size} sized modal.

+ + This is a {size} sized modal. +
@@ -882,6 +885,9 @@ export default function PlaygroundPage() { Advanced + + Modal settings with general and advanced tabs +

General settings content

diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index a81ec62c746..66ba3cfdecf 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -22,6 +22,7 @@ export type { ResourceCell, ResourceColumn, ResourceRow, + RowDragDropConfig, SelectableConfig, } from './resource/resource' export { Resource, ResourceTable } from './resource/resource' diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 298aafbf722..359f152fa0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -10,6 +10,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Textarea, @@ -239,6 +240,9 @@ export const MessageActions = memo(function MessageActions({ Give feedback + + Submit feedback about this response +

diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx index 5d1b1a76179..4f028948794 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx @@ -11,6 +11,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, } from '@/components/emcn' @@ -230,6 +231,9 @@ export function OAuthModal(props: OAuthModalProps) { Connect {providerName} + + Connect your {providerName} account to grant access +

diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 6e3e6868f96..42b15a67e6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -1,5 +1,14 @@ 'use client' -import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type DragEvent, + memo, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { ChevronLeft, ChevronRight } from 'lucide-react' import { ArrowDown, ArrowUp, Button, Checkbox, Loader, Plus, Skeleton } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -30,12 +39,25 @@ export interface ResourceRow { export interface SelectableConfig { selectedIds: Set - onSelectRow: (id: string, checked: boolean) => void + onSelectRow: (id: string, checked: boolean, shiftKey?: boolean) => void onSelectAll: (checked: boolean) => void isAllSelected: boolean disabled?: boolean } +export interface RowDragDropConfig { + activeDropTargetId?: string | null + draggedRowIds?: Set + isAnyDragActive?: boolean + isRowDraggable?: (rowId: string) => boolean + isRowDropTarget?: (rowId: string) => boolean + onDragStart?: (e: DragEvent, rowId: string) => void + onDragOver?: (e: DragEvent, rowId: string) => void + onDragLeave?: (e: DragEvent, rowId: string) => void + onDrop?: (e: DragEvent, rowId: string) => void + onDragEnd?: (e: DragEvent, rowId: string) => void +} + export interface PaginationConfig { currentPage: number totalPages: number @@ -51,10 +73,12 @@ interface ResourceProps { defaultSort?: string sort?: SortConfig headerActions?: HeaderAction[] + leadingActions?: ReactNode columns: ResourceColumn[] rows: ResourceRow[] selectedRowId?: string | null selectable?: SelectableConfig + rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void @@ -71,8 +95,6 @@ interface ResourceProps { const EMPTY_CELL_PLACEHOLDER = '- - -' const SKELETON_ROW_COUNT = 5 -const stopPropagation = (e: React.MouseEvent) => e.stopPropagation() - /** * Shared page shell for resource list pages (tables, files, knowledge, schedules, logs). * Renders the header, toolbar with search, and a data table from column/row definitions. @@ -86,10 +108,12 @@ export const Resource = memo(function Resource({ defaultSort, sort: sortOverride, headerActions, + leadingActions, columns, rows, selectedRowId, selectable, + rowDragDrop, onRowClick, onRowHover, onRowContextMenu, @@ -113,6 +137,7 @@ export const Resource = memo(function Resource({ breadcrumbs={breadcrumbs} create={create} actions={headerActions} + leadingActions={leadingActions} /> void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void @@ -172,6 +199,7 @@ export const ResourceTable = memo(function ResourceTable({ sort: externalSort, selectedRowId, selectable, + rowDragDrop, onRowClick, onRowHover, onRowContextMenu, @@ -327,6 +355,7 @@ export const ResourceTable = memo(function ResourceTable({ columns={columns} selectedRowId={selectedRowId} selectable={selectable} + rowDragDrop={rowDragDrop} onRowClick={onRowClick} onRowHover={onRowHover} onRowContextMenu={onRowContextMenu} @@ -442,6 +471,7 @@ interface DataRowProps { columns: ResourceColumn[] selectedRowId?: string | null selectable?: SelectableConfig + rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void @@ -453,16 +483,35 @@ const DataRow = memo(function DataRow({ columns, selectedRowId, selectable, + rowDragDrop, onRowClick, onRowHover, onRowContextMenu, hasCheckbox, }: DataRowProps) { const isSelected = selectable?.selectedIds.has(row.id) ?? false - - const handleClick = useCallback(() => { - onRowClick?.(row.id) - }, [onRowClick, row.id]) + const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false + const isDropTarget = rowDragDrop?.isRowDropTarget?.(row.id) ?? false + const isActiveDropTarget = rowDragDrop?.activeDropTargetId === row.id + const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false + const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false + const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0 + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if ( + selectable && + !selectable.disabled && + (e.shiftKey || e.metaKey || e.ctrlKey || !onRowClick || hasActiveSelection) + ) { + e.preventDefault() + selectable.onSelectRow(row.id, !isSelected, e.shiftKey) + return + } + onRowClick?.(row.id) + }, + [hasActiveSelection, isSelected, onRowClick, row.id, selectable] + ) const handleMouseEnter = useCallback(() => { onRowHover?.(row.id) @@ -475,25 +524,65 @@ const DataRow = memo(function DataRow({ [onRowContextMenu, row.id] ) + const shiftKeyRef = useRef(false) + + const handleSelectRowClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + shiftKeyRef.current = e.shiftKey + }, []) + const handleSelectRow = useCallback( (checked: boolean | 'indeterminate') => { - selectable?.onSelectRow(row.id, checked as boolean) + selectable?.onSelectRow(row.id, checked as boolean, shiftKeyRef.current) + shiftKeyRef.current = false }, [selectable, row.id] ) + const handleDragStart = (e: DragEvent) => { + rowDragDrop?.onDragStart?.(e, row.id) + } + + const handleDragOver = (e: DragEvent) => { + rowDragDrop?.onDragOver?.(e, row.id) + } + + const handleDragLeave = (e: DragEvent) => { + rowDragDrop?.onDragLeave?.(e, row.id) + } + + const handleDrop = (e: DragEvent) => { + rowDragDrop?.onDrop?.(e, row.id) + } + + const handleDragEnd = (e: DragEvent) => { + rowDragDrop?.onDragEnd?.(e, row.id) + } + return ( {hasCheckbox && selectable && ( @@ -503,7 +592,7 @@ const DataRow = memo(function DataRow({ onCheckedChange={handleSelectRow} disabled={selectable.disabled} aria-label='Select row' - onClick={stopPropagation} + onClick={handleSelectRowClick} /> )} @@ -553,22 +642,22 @@ interface ResourceColGroupProps { hasCheckbox?: boolean } +const CHECKBOX_WEIGHT = 0.4 + const ResourceColGroup = memo(function ResourceColGroup({ columns, hasCheckbox, }: ResourceColGroupProps) { + const weights = columns.map( + (col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) + ) + const total = (hasCheckbox ? CHECKBOX_WEIGHT : 0) + weights.reduce((s, w) => s + w, 0) + return ( - {hasCheckbox && } + {hasCheckbox && } {columns.map((col, colIdx) => ( - + ))} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..125184647b9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx @@ -0,0 +1,127 @@ +'use client' + +import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' +import { + Button, + Download, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Folder, + Tooltip, + Trash2, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' +import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' + +interface FilesActionBarProps { + selectedCount: number + onDownload?: () => void + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + onDelete?: () => void + isLoading?: boolean + className?: string +} + +export function FilesActionBar({ + selectedCount, + onDownload, + onMove, + moveOptions, + onDelete, + isLoading = false, + className, +}: FilesActionBarProps) { + return ( + + + {selectedCount > 0 && ( + +
+ + {selectedCount} selected + +
+ {onDownload && ( + + + + + Download + + )} + {onMove && moveOptions && ( + + + + + + + + Move + + + {moveOptions.length > 0 && ( + onMove(moveOptions[0].value)}> + + {moveOptions[0].label} + + )} + {moveOptions.length > 1 && } + {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} + + + )} + {onDelete && ( + + + + + Delete + + )} +
+
+
+ )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts new file mode 100644 index 00000000000..aa19162a077 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts @@ -0,0 +1 @@ +export { FilesActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx new file mode 100644 index 00000000000..045884086a0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx @@ -0,0 +1,70 @@ +'use client' + +import { memo } from 'react' +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, +} from '@/components/emcn' + +interface DeleteConfirmModalProps { + open: boolean + onOpenChange: (open: boolean) => void + fileName?: string + fileCount: number + folderCount: number + onDelete: () => void + isPending: boolean +} + +export const DeleteConfirmModal = memo(function DeleteConfirmModal({ + open, + onOpenChange, + fileName, + fileCount, + folderCount, + onDelete, + isPending, +}: DeleteConfirmModalProps) { + const totalCount = fileCount + folderCount + const hasFolders = folderCount > 0 + const title = totalCount > 1 ? 'Delete Items' : hasFolders ? 'Delete Folder' : 'Delete File' + const consequence = hasFolders + ? totalCount > 1 + ? 'This will also delete files and folders inside any selected folders.' + : 'This will also delete files and folders inside it.' + : totalCount > 1 + ? 'You can restore them from Recently Deleted in Settings.' + : 'You can restore it from Recently Deleted in Settings.' + + return ( + + + {title} + + + Are you sure you want to delete{' '} + {fileName ? ( + {fileName} + ) : ( + `${totalCount} item${totalCount === 1 ? '' : 's'}` + )} + ? {consequence} + + + + + + + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/index.ts new file mode 100644 index 00000000000..23b57d9365a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/index.ts @@ -0,0 +1 @@ +export { DeleteConfirmModal } from './delete-confirm-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx new file mode 100644 index 00000000000..1d545d0b9eb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -0,0 +1,114 @@ +'use client' + +import { memo } from 'react' +import { + Download, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Eye, + Folder, + FolderInput, + Pencil, + Trash2, +} from '@/components/emcn' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' +import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' + +interface FileRowContextMenuProps { + isOpen: boolean + position: { x: number; y: number } + onClose: () => void + onOpen: () => void + onDownload?: () => void + onRename: () => void + onDelete: () => void + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + canEdit: boolean + selectedCount: number +} + +export const FileRowContextMenu = memo(function FileRowContextMenu({ + isOpen, + position, + onClose, + onOpen, + onDownload, + onRename, + onDelete, + onMove, + moveOptions, + canEdit, + selectedCount, +}: FileRowContextMenuProps) { + const isMultiSelect = selectedCount > 1 + + return ( + !open && onClose()} modal={false}> + +
+ + e.preventDefault()} + > + {!isMultiSelect && ( + + + Open + + )} + {onDownload && ( + + + {isMultiSelect ? `Download ${selectedCount} items` : 'Download'} + + )} + {canEdit && ( + <> + + {!isMultiSelect && ( + + + Rename + + )} + {onMove && moveOptions && moveOptions.length > 0 && ( + + + + {isMultiSelect ? `Move ${selectedCount} items` : 'Move to'} + + + onMove(moveOptions[0].value)}> + + {moveOptions[0].label} + + {moveOptions.length > 1 && } + {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} + + + )} + + + {isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'} + + + )} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/index.ts new file mode 100644 index 00000000000..d53dca37c19 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/index.ts @@ -0,0 +1 @@ +export { FileRowContextMenu } from './file-row-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 53072bced76..7af1ea24fdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -631,7 +631,7 @@ const STATIC_MARKDOWN_COMPONENTS = {
), thead: ({ children }: { children?: React.ReactNode }) => ( - {children} + {children} ), tbody: ({ children }: { children?: React.ReactNode }) => {children}, tr: ({ children }: { children?: React.ReactNode }) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx index 031213ead7f..1954a1fcb05 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx @@ -7,15 +7,17 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/emcn' -import { Plus, Upload } from '@/components/emcn/icons' +import { FolderPlus, Plus, Upload } from '@/components/emcn/icons' interface FilesListContextMenuProps { isOpen: boolean position: { x: number; y: number } onClose: () => void onCreateFile?: () => void + onCreateFolder?: () => void onUploadFile?: () => void disableCreate?: boolean + disableCreateFolder?: boolean disableUpload?: boolean } @@ -24,22 +26,18 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({ position, onClose, onCreateFile, + onCreateFolder, onUploadFile, disableCreate = false, + disableCreateFolder = false, disableUpload = false, }: FilesListContextMenuProps) { return ( !open && onClose()} modal={false}>
@@ -56,6 +54,12 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({ New file )} + {onCreateFolder && ( + + + New folder + + )} {onUploadFile && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 7e304d786ba..ec1147a8fee 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1,7 +1,8 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { Button, @@ -9,24 +10,22 @@ import { Combobox, type ComboboxOption, Download, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, Eye, + File as FilesIcon, + Folder, + FolderPlus, Loader, Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Pencil, - Trash, + Trash2, toast, Upload, } from '@/components/emcn' -import { File as FilesIcon } from '@/components/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { triggerFileDownload } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -47,10 +46,12 @@ import { SUPPORTED_VIDEO_EXTENSIONS, } from '@/lib/uploads/utils/validation' import type { + BreadcrumbItem, FilterTag, HeaderAction, ResourceColumn, ResourceRow, + RowDragDropConfig, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' @@ -61,6 +62,9 @@ import { ResourceHeader, timeCell, } from '@/app/workspace/[workspaceId]/components' +import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' +import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' +import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FileViewer, @@ -68,9 +72,18 @@ import { isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { + useBulkArchiveWorkspaceFileItems, + useCreateWorkspaceFileFolder, + useMoveWorkspaceFileItems, + useUpdateWorkspaceFileFolder, + useWorkspaceFileFolders, + type WorkspaceFileFolderApi, +} from '@/hooks/queries/workspace-file-folders' import { useDeleteWorkspaceFile, useRenameWorkspaceFile, @@ -82,6 +95,9 @@ import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' +type FileResourceItem = + | { kind: 'file'; id: string; file: WorkspaceFileRecord } + | { kind: 'folder'; id: string; folder: WorkspaceFileFolderApi } const logger = createLogger('Files') @@ -120,6 +136,20 @@ const MIME_TYPE_LABELS: Record = { 'text/markdown': 'Markdown', } +const EMPTY_WORKSPACE_FILES: WorkspaceFileRecord[] = [] +const EMPTY_WORKSPACE_FILE_FOLDERS: WorkspaceFileFolderApi[] = [] + +const fileRowId = (id: string) => `file:${id}` +const folderRowId = (id: string) => `folder:${id}` +const parseRowId = (rowId: string): { kind: 'file' | 'folder'; id: string } => { + if (rowId.startsWith('folder:')) return { kind: 'folder', id: rowId.slice('folder:'.length) } + if (rowId.startsWith('file:')) return { kind: 'file', id: rowId.slice('file:'.length) } + return { kind: 'file', id: rowId } +} + +const hasExternalFiles = (dataTransfer: DataTransfer): boolean => + dataTransfer.types.includes('Files') + function formatFileType(mimeType: string | null, filename: string): string { if (mimeType && MIME_TYPE_LABELS[mimeType]) { return MIME_TYPE_LABELS[mimeType] @@ -143,11 +173,13 @@ export function Files() { const router = useRouter() const searchParams = useSearchParams() const isNewFile = searchParams.get('new') === '1' + const currentFolderId = searchParams.get('folderId') const workspaceId = params?.workspaceId as string const fileIdFromRoute = typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true const { config: permissionConfig } = usePermissionConfig() useEffect(() => { @@ -156,11 +188,17 @@ export function Files() { } }, [permissionConfig.hideFilesTab, router, workspaceId]) - const { data: files = [], isLoading, error } = useWorkspaceFiles(workspaceId) + const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) + const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, isLoading: foldersLoading } = + useWorkspaceFileFolders(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const uploadFile = useUploadWorkspaceFile() const deleteFile = useDeleteWorkspaceFile() const renameFile = useRenameWorkspaceFile() + const createFolder = useCreateWorkspaceFileFolder() + const updateFolder = useUpdateWorkspaceFileFolder() + const moveItems = useMoveWorkspaceFileItems() + const bulkArchiveItems = useBulkArchiveWorkspaceFileItems() const { isOpen: isContextMenuOpen, @@ -183,6 +221,8 @@ export function Files() { const justCreatedFileIdRef = useRef(null) const filesRef = useRef(files) filesRef.current = files + const foldersRef = useRef(folders) + foldersRef.current = folders const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ @@ -205,6 +245,9 @@ export function Files() { const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') + const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) + const [activeDropTargetId, setActiveDropTargetId] = useState(null) + const [draggedRowIds, setDraggedRowIds] = useState>(() => new Set()) const [previewMode, setPreviewMode] = useState(() => { if (isNewFile) return 'editor' if (fileIdFromRoute) { @@ -216,13 +259,25 @@ export function Files() { }) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const contextMenuFileRef = useRef(null) - const [deleteTargetFile, setDeleteTargetFile] = useState<{ id: string; name: string } | null>( - null - ) + const contextMenuItemRef = useRef(null) + const lastSelectedIndexRef = useRef(-1) + const draggedRowIdsRef = useRef([]) + const dragGhostRef = useRef(null) + const [deleteTarget, setDeleteTarget] = useState<{ + fileIds: string[] + folderIds: string[] + name: string + } | null>(null) const listRename = useInlineRename({ - onSave: (fileId, name) => renameFile.mutate({ workspaceId, fileId, name }), + onSave: (rowId, name) => { + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + updateFolder.mutate({ workspaceId, folderId: parsed.id, updates: { name } }) + return + } + renameFile.mutate({ workspaceId, fileId: parsed.id, name }) + }, }) const headerRename = useInlineRename({ @@ -231,6 +286,12 @@ export function Files() { }, }) + const breadcrumbRename = useInlineRename({ + onSave: (folderId, name) => { + updateFolder.mutate({ workspaceId, folderId, updates: { name } }) + }, + }) + const selectedFile = useMemo( () => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null), [fileIdFromRoute, files] @@ -238,10 +299,40 @@ export function Files() { const selectedFileRef = useRef(selectedFile) selectedFileRef.current = selectedFile + const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) + const currentFolder = currentFolderId ? (folderById.get(currentFolderId) ?? null) : null + const currentFolderPath = currentFolder?.path ?? null + + const visibleFolders = useMemo(() => { + const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) + const searched = debouncedSearchTerm + ? siblings.filter((folder) => + folder.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + ) + : siblings + const col = activeSort?.column ?? 'name' + const dir = activeSort?.direction ?? 'asc' + return [...searched].sort((a, b) => { + let cmp = 0 + if (col === 'updated') { + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + } else if (col === 'created') { + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + } else { + cmp = a.name.localeCompare(b.name) + } + return dir === 'asc' ? cmp : -cmp + }) + }, [folders, currentFolderId, debouncedSearchTerm, activeSort]) + const filteredFiles = useMemo(() => { let result = debouncedSearchTerm - ? files.filter((f) => f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - : files + ? files.filter( + (f) => + (f.folderId ?? null) === currentFolderId && + f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + ) + : files.filter((f) => (f.folderId ?? null) === currentFolderId) if (typeFilter.length > 0) { result = result.filter((f) => { @@ -296,28 +387,48 @@ export function Files() { } return dir === 'asc' ? cmp : -cmp }) - }, [files, debouncedSearchTerm, typeFilter, sizeFilter, uploadedByFilter, activeSort, members]) - - const rowCacheRef = useRef( - new Map() - ) + }, [ + files, + currentFolderId, + debouncedSearchTerm, + typeFilter, + sizeFilter, + uploadedByFilter, + activeSort, + members, + ]) const baseRows: ResourceRow[] = useMemo(() => { - const prevCache = rowCacheRef.current - const nextCache = new Map< - string, - { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members } - >() - - const result = filteredFiles.map((file) => { - const cached = prevCache.get(file.id) - if (cached && cached.file === file && cached.members === members) { - nextCache.set(file.id, cached) - return cached.row - } + const folderRows = visibleFolders.map((folder) => ({ + id: folderRowId(folder.id), + cells: { + name: { + icon: , + label: folder.name, + }, + size: { label: 'Folder' }, + type: { + icon: , + label: 'Folder', + }, + created: timeCell(folder.createdAt), + owner: ownerCell(folder.userId, members), + updated: timeCell(folder.updatedAt), + }, + sortValues: { + name: folder.name, + size: -1, + type: 'Folder', + created: new Date(folder.createdAt).getTime(), + updated: new Date(folder.updatedAt).getTime(), + owner: members?.find((m) => m.userId === folder.userId)?.name ?? '', + }, + })) + + const fileRows = filteredFiles.map((file) => { const Icon = getDocumentIcon(file.type || '', file.name) const row: ResourceRow = { - id: file.id, + id: fileRowId(file.id), cells: { name: { icon: , @@ -334,22 +445,28 @@ export function Files() { owner: ownerCell(file.uploadedBy, members), updated: timeCell(file.updatedAt), }, + sortValues: { + name: file.name, + size: file.size, + type: formatFileType(file.type, file.name), + created: new Date(file.uploadedAt).getTime(), + updated: new Date(file.updatedAt).getTime(), + owner: members?.find((m) => m.userId === file.uploadedBy)?.name ?? '', + }, } - nextCache.set(file.id, { row, file, members }) return row }) - rowCacheRef.current = nextCache - return result - }, [filteredFiles, members]) + return [...folderRows, ...fileRows] + }, [visibleFolders, filteredFiles, members]) const rows: ResourceRow[] = useMemo(() => { if (!listRename.editingId) return baseRows return baseRows.map((row) => { if (row.id !== listRename.editingId) return row - const file = filteredFiles.find((f) => f.id === row.id) - if (!file) return row - const Icon = getDocumentIcon(file.type || '', file.name) + const parsed = parseRowId(row.id) + const file = parsed.kind === 'file' ? filteredFiles.find((f) => f.id === parsed.id) : null + const Icon = file ? getDocumentIcon(file.type || '', file.name) : Folder return { ...row, cells: { @@ -373,78 +490,359 @@ export function Files() { }, } }) - }, [ - baseRows, - listRename.editingId, - listRename.editValue, - listRename.setEditValue, - listRename.submitRename, - listRename.cancelRename, - filteredFiles, - ]) + }, [baseRows, listRename.editingId, listRename.editValue, filteredFiles]) - const uploadFiles = async (filesToUpload: File[]) => { - if (!workspaceId || filesToUpload.length === 0) return + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) - const oversized: string[] = [] - const sizeFiltered = filesToUpload.filter((f) => { - if (f.size > MAX_WORKSPACE_FILE_SIZE) { - oversized.push(f.name) - return false - } - return true + const prevVisibleRowIdsRef = useRef(visibleRowIds) + useEffect(() => { + if (prevVisibleRowIdsRef.current === visibleRowIds) return + prevVisibleRowIdsRef.current = visibleRowIds + lastSelectedIndexRef.current = -1 + const visible = new Set(visibleRowIds) + setSelectedRowIds((prev) => { + if (prev.size === 0) return prev + const next = new Set(Array.from(prev).filter((id) => visible.has(id))) + return next.size === prev.size ? prev : next }) - if (oversized.length > 0) { - toast.error( - oversized.length === 1 - ? `${oversized[0]} exceeds the 5 GiB upload limit` - : `${oversized.length} files exceed the 5 GiB upload limit` - ) + }, [visibleRowIds]) + + const isAllSelected = + visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) + const selectedFileIds = useMemo( + () => + Array.from(selectedRowIds) + .map(parseRowId) + .filter((item) => item.kind === 'file') + .map((item) => item.id), + [selectedRowIds] + ) + const selectedFolderIds = useMemo( + () => + Array.from(selectedRowIds) + .map(parseRowId) + .filter((item) => item.kind === 'folder') + .map((item) => item.id), + [selectedRowIds] + ) + + const selectableConfig = useMemo( + () => ({ + selectedIds: selectedRowIds, + isAllSelected, + onSelectRow: (rowId: string, checked: boolean, shiftKey?: boolean) => { + const currentIndex = visibleRowIds.indexOf(rowId) + if (shiftKey && lastSelectedIndexRef.current !== -1 && currentIndex !== -1) { + const start = Math.min(lastSelectedIndexRef.current, currentIndex) + const end = Math.max(lastSelectedIndexRef.current, currentIndex) + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) + return next + }) + lastSelectedIndexRef.current = currentIndex + } else { + setSelectedRowIds((prev) => { + const next = new Set(prev) + if (checked) next.add(rowId) + else next.delete(rowId) + return next + }) + if (checked) lastSelectedIndexRef.current = currentIndex + else lastSelectedIndexRef.current = -1 + } + }, + onSelectAll: (checked: boolean) => { + lastSelectedIndexRef.current = -1 + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (const rowId of visibleRowIds) { + if (checked) next.add(rowId) + else next.delete(rowId) + } + return next + }) + }, + disabled: false, + }), + [selectedRowIds, isAllSelected, visibleRowIds] + ) + + const descendantFolderIdsByFolderId = useMemo(() => { + const childrenByParent = new Map() + for (const folder of folders) { + if (!folder.parentId) continue + const children = childrenByParent.get(folder.parentId) ?? [] + children.push(folder.id) + childrenByParent.set(folder.parentId, children) } - const unsupported: string[] = [] - const allowedFiles = sizeFiltered.filter((f) => { - const ext = getFileExtension(f.name) - const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number]) - if (!ok) unsupported.push(f.name) - return ok - }) + const result = new Map>() + const collect = (folderId: string, seen = new Set()): Set => { + const cached = result.get(folderId) + if (cached) return cached + if (seen.has(folderId)) return new Set() + + const nextSeen = new Set(seen) + nextSeen.add(folderId) + const descendants = new Set() + for (const childId of childrenByParent.get(folderId) ?? []) { + if (nextSeen.has(childId)) continue + descendants.add(childId) + for (const nestedId of collect(childId, nextSeen)) { + descendants.add(nestedId) + } + } + result.set(folderId, descendants) + return descendants + } - if (unsupported.length > 0) { - logger.warn('Unsupported file types skipped:', unsupported) + for (const folder of folders) { + collect(folder.id) } + return result + }, [folders]) + + const isInvalidDropTarget = useCallback( + (targetRowId: string, sourceRowIds: string[]) => { + const target = parseRowId(targetRowId) + if (target.kind !== 'folder') return true + + for (const sourceRowId of sourceRowIds) { + const source = parseRowId(sourceRowId) + if (source.kind !== 'folder') continue + if (source.id === target.id) return true + if (descendantFolderIdsByFolderId.get(source.id)?.has(target.id)) return true + } + + // Reject drop if every dragged item is already a direct child of the target + const allAlreadyInTarget = sourceRowIds.every((sourceRowId) => { + const source = parseRowId(sourceRowId) + if (source.kind === 'file') { + return filesRef.current.find((f) => f.id === source.id)?.folderId === target.id + } + return (foldersRef.current.find((f) => f.id === source.id)?.parentId ?? null) === target.id + }) + if (allAlreadyInTarget) return true - if (allowedFiles.length === 0) return + return false + }, + [descendantFolderIdsByFolderId] + ) - try { - setUploading(true) - setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) + const uploadFiles = useCallback( + async (filesToUpload: File[], targetFolderId = currentFolderId) => { + if (!workspaceId || filesToUpload.length === 0 || !canEdit) return - for (let i = 0; i < allowedFiles.length; i++) { - try { - await uploadFile.mutateAsync({ + const oversized: string[] = [] + const sizeFiltered = filesToUpload.filter((f) => { + if (f.size > MAX_WORKSPACE_FILE_SIZE) { + oversized.push(f.name) + return false + } + return true + }) + if (oversized.length > 0) { + toast.error( + oversized.length === 1 + ? `${oversized[0]} exceeds the 5 GiB upload limit` + : `${oversized.length} files exceed the 5 GiB upload limit` + ) + } + + const unsupported: string[] = [] + const allowedFiles = sizeFiltered.filter((f) => { + const ext = getFileExtension(f.name) + const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number]) + if (!ok) unsupported.push(f.name) + return ok + }) + + if (unsupported.length > 0) { + logger.warn('Unsupported file types skipped:', unsupported) + } + + if (allowedFiles.length === 0) return + + try { + setUploading(true) + setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) + + for (let i = 0; i < allowedFiles.length; i++) { + try { + await uploadFile.mutateAsync({ + workspaceId, + file: allowedFiles[i], + folderId: targetFolderId, + onProgress: ({ percent }) => { + setUploadProgress((prev) => ({ ...prev, currentPercent: percent })) + }, + }) + setUploadProgress({ + completed: i + 1, + total: allowedFiles.length, + currentPercent: 0, + }) + } catch (err) { + logger.error('Error uploading file:', err) + } + } + } catch (err) { + logger.error('Error uploading file:', err) + } finally { + setUploading(false) + setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) + } + }, + [workspaceId, canEdit, currentFolderId] + ) + + const rowDragDropConfig = useMemo( + () => ({ + activeDropTargetId, + draggedRowIds, + isAnyDragActive: draggedRowIds.size > 0, + isRowDraggable: (rowId) => canEdit && listRename.editingId !== rowId, + isRowDropTarget: (rowId) => canEdit && parseRowId(rowId).kind === 'folder', + onDragStart: (e: DragEvent, rowId) => { + if (!canEdit || listRename.editingId === rowId) { + e.preventDefault() + return + } + + const sourceRowIds = selectedRowIds.has(rowId) + ? visibleRowIds.filter((visibleRowId) => selectedRowIds.has(visibleRowId)) + : [rowId] + + draggedRowIdsRef.current = sourceRowIds + setDraggedRowIds(new Set(sourceRowIds)) + if (!selectedRowIds.has(rowId)) { + setSelectedRowIds(new Set([rowId])) + } + + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData( + 'application/x-sim-workspace-file-rows', + JSON.stringify(sourceRowIds) + ) + e.dataTransfer.setData('text/plain', sourceRowIds.join(',')) + + const count = sourceRowIds.length + const firstParsed = parseRowId(sourceRowIds[0]) + const firstName = + firstParsed.kind === 'file' + ? filesRef.current.find((f) => f.id === firstParsed.id)?.name + : foldersRef.current.find((f) => f.id === firstParsed.id)?.name + const ghostLabel = + count > 1 ? `${firstName ?? 'Items'} +${count - 1} more` : (firstName ?? 'Item') + const ghost = document.createElement('div') + ghost.style.cssText = + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + const text = document.createElement('span') + text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' + text.textContent = ghostLabel + ghost.appendChild(text) + document.body.appendChild(ghost) + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + }, + onDragOver: (e: DragEvent, rowId) => { + const sourceRowIds = draggedRowIdsRef.current + const isExternalFileDrag = hasExternalFiles(e.dataTransfer) + if (!isExternalFileDrag && isInvalidDropTarget(rowId, sourceRowIds)) return + + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' + setActiveDropTargetId(rowId) + }, + onDragLeave: (e: DragEvent, rowId) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setActiveDropTargetId((current) => (current === rowId ? null : current)) + }, + onDrop: (e: DragEvent, rowId) => { + e.preventDefault() + e.stopPropagation() + dragCounterRef.current = 0 + setIsDraggingOver(false) + setActiveDropTargetId(null) + const target = parseRowId(rowId) + if (target.kind !== 'folder') return + + const droppedFiles = Array.from(e.dataTransfer.files ?? []) + if (droppedFiles.length > 0) { + void uploadFiles(droppedFiles, target.id) + return + } + + let sourceRowIds = draggedRowIdsRef.current + const rawSource = e.dataTransfer.getData('application/x-sim-workspace-file-rows') + if (rawSource) { + try { + const parsedSource = JSON.parse(rawSource) + if (Array.isArray(parsedSource)) { + sourceRowIds = parsedSource.filter( + (source): source is string => typeof source === 'string' && source.length > 0 + ) + } + } catch { + sourceRowIds = draggedRowIdsRef.current + } + } + + if (isInvalidDropTarget(rowId, sourceRowIds)) return + + const fileIds = sourceRowIds + .map(parseRowId) + .filter((source) => source.kind === 'file') + .map((source) => source.id) + const folderIds = sourceRowIds + .map(parseRowId) + .filter((source) => source.kind === 'folder') + .map((source) => source.id) + + if (fileIds.length === 0 && folderIds.length === 0) return + + void moveItems + .mutateAsync({ workspaceId, - file: allowedFiles[i], - onProgress: ({ percent }) => { - setUploadProgress((prev) => ({ ...prev, currentPercent: percent })) - }, + fileIds, + folderIds, + targetFolderId: target.id, + }) + .then(() => { + setSelectedRowIds(new Set()) }) - setUploadProgress({ - completed: i + 1, - total: allowedFiles.length, - currentPercent: 0, + .catch((error) => { + logger.error('Failed to move items via drag and drop:', error) }) - } catch (err) { - logger.error('Error uploading file:', err) + }, + onDragEnd: () => { + if (dragGhostRef.current) { + dragGhostRef.current.remove() + dragGhostRef.current = null } - } - } catch (err) { - logger.error('Error uploading file:', err) - } finally { - setUploading(false) - setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) - } - } + dragCounterRef.current = 0 + draggedRowIdsRef.current = [] + setDraggedRowIds(new Set()) + setIsDraggingOver(false) + setActiveDropTargetId(null) + }, + }), + [ + activeDropTargetId, + draggedRowIds, + canEdit, + listRename.editingId, + selectedRowIds, + visibleRowIds, + isInvalidDropTarget, + uploadFiles, + workspaceId, + ] + ) const handleFileChange = async (e: React.ChangeEvent) => { const list = e.target.files @@ -454,22 +852,26 @@ export function Files() { } const handleDragEnter = (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() dragCounterRef.current++ - if (e.dataTransfer.types.includes('Files')) setIsDraggingOver(true) + setIsDraggingOver(true) } - const handleDragLeave = () => { + const handleDragLeave = (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return dragCounterRef.current-- if (dragCounterRef.current === 0) setIsDraggingOver(false) } const handleDragOver = (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() e.dataTransfer.dropEffect = 'copy' } const handleDrop = async (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() dragCounterRef.current = 0 setIsDraggingOver(false) @@ -485,50 +887,73 @@ export function Files() { } }, []) - const deleteTargetFileRef = useRef(deleteTargetFile) - deleteTargetFileRef.current = deleteTargetFile + const deleteTargetRef = useRef(deleteTarget) + deleteTargetRef.current = deleteTarget const fileIdFromRouteRef = useRef(fileIdFromRoute) fileIdFromRouteRef.current = fileIdFromRoute const handleDelete = useCallback(async () => { - const target = deleteTargetFileRef.current + const target = deleteTargetRef.current if (!target) return try { - await deleteFile.mutateAsync({ - workspaceId, - fileId: target.id, - }) + if (target.folderIds.length > 0 || target.fileIds.length > 1) { + await bulkArchiveItems.mutateAsync({ + workspaceId, + fileIds: target.fileIds, + folderIds: target.folderIds, + }) + } else if (target.fileIds.length === 1) { + await deleteFile.mutateAsync({ + workspaceId, + fileId: target.fileIds[0], + }) + } else { + setShowDeleteConfirm(false) + setDeleteTarget(null) + return + } setShowDeleteConfirm(false) - setDeleteTargetFile(null) - if (fileIdFromRouteRef.current === target.id) { + setDeleteTarget(null) + setSelectedRowIds(new Set()) + if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') - router.push(`/workspace/${workspaceId}/files`) + router.push( + currentFolderId + ? `/workspace/${workspaceId}/files?folderId=${currentFolderId}` + : `/workspace/${workspaceId}/files` + ) } } catch (err) { logger.error('Failed to delete file:', err) } - }, [workspaceId, router]) + }, [workspaceId, router, currentFolderId]) const isDirtyRef = useRef(isDirty) isDirtyRef.current = isDirty const saveStatusRef = useRef(saveStatus) saveStatusRef.current = saveStatus + const pendingFileNavigationUrlRef = useRef(null) const handleSave = useCallback(async () => { if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return await saveRef.current() }, []) - const handleBackAttempt = useCallback(() => { - if (isDirtyRef.current) { - setShowUnsavedChangesAlert(true) - } else { + const handleNavigateFromFileDetail = useCallback( + (url: string) => { + if (isDirtyRef.current) { + pendingFileNavigationUrlRef.current = url + setShowUnsavedChangesAlert(true) + return + } + setPreviewMode('editor') - router.push(`/workspace/${workspaceId}/files`) - } - }, [router, workspaceId]) + router.push(url) + }, + [router] + ) const handleStartHeaderRename = useCallback(() => { const file = selectedFileRef.current @@ -543,64 +968,115 @@ export function Files() { const handleDeleteSelected = useCallback(() => { const file = selectedFileRef.current if (file) { - setDeleteTargetFile({ id: file.id, name: file.name }) + setDeleteTarget({ fileIds: [file.id], folderIds: [], name: file.name }) setShowDeleteConfirm(true) } }, []) - const fileDetailBreadcrumbs = useMemo( - () => - selectedFile - ? [ - { label: 'Files', onClick: handleBackAttempt }, - { - label: selectedFile.name, - editing: headerRename.editingId - ? { - isEditing: true, - value: headerRename.editValue, - onChange: headerRename.setEditValue, - onSubmit: headerRename.submitRename, - onCancel: headerRename.cancelRename, - } - : undefined, - dropdownItems: [ - { - label: 'Rename', - icon: Pencil, - onClick: handleStartHeaderRename, - }, - { - label: 'Download', - icon: Download, - onClick: handleDownloadSelected, - }, - { - label: 'Delete', - icon: Trash, - onClick: handleDeleteSelected, - }, - ], - }, - ] - : [], - [ - selectedFile, - handleBackAttempt, - headerRename.editingId, - headerRename.editValue, - handleStartHeaderRename, - handleDownloadSelected, - handleDeleteSelected, + const handleBulkDelete = useCallback(() => { + if (selectedFileIds.length === 0 && selectedFolderIds.length === 0) return + setDeleteTarget({ + fileIds: selectedFileIds, + folderIds: selectedFolderIds, + name: + selectedFileIds.length + selectedFolderIds.length === 1 + ? (files.find((file) => file.id === selectedFileIds[0])?.name ?? + folders.find((folder) => folder.id === selectedFolderIds[0])?.name ?? + 'selected item') + : `${selectedFileIds.length + selectedFolderIds.length} selected items`, + }) + setShowDeleteConfirm(true) + }, [selectedFileIds, selectedFolderIds, files, folders]) + + const handleBulkDownload = useCallback(() => { + const selectedFiles = files.filter((file) => selectedFileIds.includes(file.id)) + if (selectedFiles.length === 1 && selectedFolderIds.length === 0) { + handleDownload(selectedFiles[0]) + return + } + + const query = new URLSearchParams() + for (const fileId of selectedFileIds) query.append('fileIds', fileId) + for (const folderId of selectedFolderIds) query.append('folderIds', folderId) + + if (query.size === 0) return + window.location.href = `/api/workspaces/${workspaceId}/files/download?${query.toString()}` + }, [selectedFileIds, selectedFolderIds, files, handleDownload, workspaceId]) + + const fileDetailBreadcrumbs = useMemo(() => { + if (!selectedFile) return [] + + const folderBreadcrumbs: BreadcrumbItem[] = [] + const visitedFolderIds = new Set() + let folderId = selectedFile.folderId + + while (folderId && !visitedFolderIds.has(folderId)) { + visitedFolderIds.add(folderId) + const folder = folderById.get(folderId) + if (!folder) break + + folderBreadcrumbs.unshift({ + label: folder.name, + onClick: () => + handleNavigateFromFileDetail(`/workspace/${workspaceId}/files?folderId=${folder.id}`), + }) + folderId = folder.parentId + } + + return [ + { + label: 'Files', + onClick: () => handleNavigateFromFileDetail(`/workspace/${workspaceId}/files`), + }, + ...folderBreadcrumbs, + { + label: selectedFile.name, + editing: headerRename.editingId + ? { + isEditing: true, + value: headerRename.editValue, + onChange: headerRename.setEditValue, + onSubmit: headerRename.submitRename, + onCancel: headerRename.cancelRename, + } + : undefined, + dropdownItems: [ + { label: 'Download', icon: Download, onClick: handleDownloadSelected }, + ...(canEdit + ? [ + { label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename }, + { label: 'Delete', icon: Trash2, onClick: handleDeleteSelected }, + ] + : []), + ], + }, ] - ) + }, [ + selectedFile, + folderById, + handleNavigateFromFileDetail, + workspaceId, + canEdit, + headerRename.editingId, + headerRename.editValue, + handleStartHeaderRename, + handleDownloadSelected, + handleDeleteSelected, + ]) const handleDiscardChanges = () => { setShowUnsavedChangesAlert(false) setIsDirty(false) setSaveStatus('idle') setPreviewMode('editor') - router.push(`/workspace/${workspaceId}/files`) + const folderId = selectedFileRef.current?.folderId + const targetUrl = + pendingFileNavigationUrlRef.current ?? + (folderId + ? `/workspace/${workspaceId}/files?folderId=${folderId}` + : `/workspace/${workspaceId}/files`) + pendingFileNavigationUrlRef.current = null + router.push(targetUrl) } const creatingFileRef = useRef(creatingFile) @@ -611,7 +1087,9 @@ export function Files() { setCreatingFile(true) try { - const existingNames = new Set(filesRef.current.map((f) => f.name)) + const existingNames = new Set( + filesRef.current.filter((f) => (f.folderId ?? null) === currentFolderId).map((f) => f.name) + ) let name = 'untitled.md' let counter = 1 while (existingNames.has(name)) { @@ -622,57 +1100,152 @@ export function Files() { const mimeType = getMimeTypeFromExtension('md') const blob = new Blob([''], { type: mimeType }) const file = new File([blob], name, { type: mimeType }) - const result = await uploadFile.mutateAsync({ workspaceId, file, skipToast: true }) + const result = await uploadFile.mutateAsync({ + workspaceId, + file, + folderId: currentFolderId, + skipToast: true, + }) const fileId = result.file?.id if (fileId) { justCreatedFileIdRef.current = fileId - router.push(`/workspace/${workspaceId}/files/${fileId}?new=1`) + const params = new URLSearchParams({ new: '1' }) + if (currentFolderId) params.set('folderId', currentFolderId) + router.push(`/workspace/${workspaceId}/files/${fileId}?${params.toString()}`) } } catch (err) { logger.error('Failed to create file:', err) } finally { setCreatingFile(false) } - }, [workspaceId, router]) + }, [workspaceId, router, currentFolderId]) + + const handleCreateFolder = useCallback(async () => { + if (!workspaceId) return + const existingNames = new Set( + folders + .filter((folder) => (folder.parentId ?? null) === currentFolderId) + .map((folder) => folder.name) + ) + let name = 'New folder' + let counter = 1 + while (existingNames.has(name)) { + name = `New folder (${counter})` + counter++ + } + + try { + const folder = await createFolder.mutateAsync({ + workspaceId, + name, + parentId: currentFolderId, + }) + listRename.startRename(folderRowId(folder.id), folder.name) + } catch (error) { + logger.error('Failed to create folder:', error) + toast.error(toError(error).message) + } + }, [workspaceId, folders, currentFolderId, listRename.startRename]) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const file = filesRef.current.find((f) => f.id === rowId) - if (file) { - contextMenuFileRef.current = file - openContextMenu(e) + const parsed = parseRowId(rowId) + const item = + parsed.kind === 'folder' + ? folders.find((folder) => folder.id === parsed.id) + : filesRef.current.find((file) => file.id === parsed.id) + if (!item) return + contextMenuItemRef.current = + parsed.kind === 'folder' + ? { kind: 'folder', id: parsed.id, folder: item as WorkspaceFileFolderApi } + : { kind: 'file', id: parsed.id, file: item as WorkspaceFileRecord } + if (!selectedRowIds.has(rowId)) { + lastSelectedIndexRef.current = visibleRowIds.indexOf(rowId) + setSelectedRowIds(new Set([rowId])) } + openContextMenu(e) }, - [openContextMenu] + [folders, openContextMenu, selectedRowIds, visibleRowIds] ) const handleContextMenuOpen = useCallback(() => { - const file = contextMenuFileRef.current - if (!file) return - router.push(`/workspace/${workspaceId}/files/${file.id}`) + const item = contextMenuItemRef.current + if (!item) return + if (item.kind === 'folder') { + router.push(`/workspace/${workspaceId}/files?folderId=${item.folder.id}`) + closeContextMenu() + return + } + router.push( + item.file.folderId + ? `/workspace/${workspaceId}/files/${item.file.id}?folderId=${item.file.folderId}` + : `/workspace/${workspaceId}/files/${item.file.id}` + ) closeContextMenu() }, [closeContextMenu, router, workspaceId]) const handleContextMenuDownload = useCallback(() => { - const file = contextMenuFileRef.current - if (!file) return - handleDownload(file) + const item = contextMenuItemRef.current + if (!item) return + const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { + handleBulkDownload() + closeContextMenu() + return + } + if (item.kind === 'folder') { + window.location.href = `/api/workspaces/${workspaceId}/files/download?folderIds=${encodeURIComponent(item.folder.id)}` + closeContextMenu() + return + } + handleDownload(item.file) closeContextMenu() - }, [handleDownload, closeContextMenu]) + }, [selectedRowIds, handleBulkDownload, closeContextMenu, workspaceId, handleDownload]) const handleContextMenuRename = useCallback(() => { - const file = contextMenuFileRef.current - if (file) listRename.startRename(file.id, file.name) + const item = contextMenuItemRef.current + if (item?.kind === 'file') listRename.startRename(fileRowId(item.file.id), item.file.name) + if (item?.kind === 'folder') + listRename.startRename(folderRowId(item.folder.id), item.folder.name) closeContextMenu() }, [listRename.startRename, closeContextMenu]) const handleContextMenuDelete = useCallback(() => { - const file = contextMenuFileRef.current - if (!file) return - setDeleteTargetFile({ id: file.id, name: file.name }) + const item = contextMenuItemRef.current + if (!item) return + const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { + handleBulkDelete() + closeContextMenu() + return + } + setDeleteTarget( + item.kind === 'file' + ? { fileIds: [item.file.id], folderIds: [], name: item.file.name } + : { fileIds: [], folderIds: [item.folder.id], name: item.folder.name } + ) setShowDeleteConfirm(true) closeContextMenu() - }, [closeContextMenu]) + }, [selectedRowIds, handleBulkDelete, closeContextMenu]) + + const handleContextMenuMove = useCallback( + async (optionValue: string) => { + const targetFolderId = optionValue === '__root__' ? null : optionValue + try { + await moveItems.mutateAsync({ + workspaceId, + fileIds: selectedFileIds, + folderIds: selectedFolderIds, + targetFolderId, + }) + setSelectedRowIds(new Set()) + closeContextMenu() + } catch (error) { + logger.error('Failed to move items:', error) + } + }, + [workspaceId, selectedFileIds, selectedFolderIds, closeContextMenu] + ) const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { @@ -689,12 +1262,14 @@ export function Files() { ) const handleListUploadFile = useCallback(() => { + if (!canEdit || uploading) return fileInputRef.current?.click() closeListContextMenu() - }, [closeListContextMenu]) + }, [canEdit, uploading, closeListContextMenu]) const prevFileIdRef = useRef(fileIdFromRoute) - if (fileIdFromRoute !== prevFileIdRef.current) { + useEffect(() => { + if (fileIdFromRoute === prevFileIdRef.current) return prevFileIdRef.current = fileIdFromRoute const isJustCreated = isNewFile || (fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute) @@ -709,16 +1284,18 @@ export function Files() { : null return file && isPreviewable(file) ? 'preview' : 'editor' })() - if (nextMode !== previewMode) { - setPreviewMode(nextMode) - } - } + setPreviewMode((current) => (nextMode === current ? current : nextMode)) + }, [fileIdFromRoute, isNewFile]) useEffect(() => { if (isNewFile && fileIdFromRoute) { - router.replace(`/workspace/${workspaceId}/files/${fileIdFromRoute}`) + router.replace( + currentFolderId + ? `/workspace/${workspaceId}/files/${fileIdFromRoute}?folderId=${currentFolderId}` + : `/workspace/${workspaceId}/files/${fileIdFromRoute}` + ) } - }, [isNewFile, fileIdFromRoute, router, workspaceId]) + }, [isNewFile, fileIdFromRoute, router, workspaceId, currentFolderId]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -740,6 +1317,49 @@ export function Files() { } }, [handleSave]) + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + const visibleRowIdsRef = useRef(visibleRowIds) + visibleRowIdsRef.current = visibleRowIds + const listRenameActiveRef = useRef(listRename.editingId) + listRenameActiveRef.current = listRename.editingId + const handleBulkDeleteRef = useRef(handleBulkDelete) + handleBulkDeleteRef.current = handleBulkDelete + + useEffect(() => { + const handleListKeyDown = (e: KeyboardEvent) => { + if (fileIdFromRouteRef.current) return + const active = document.activeElement + if ( + active && + (active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA' || + (active as HTMLElement).isContentEditable) + ) + return + if (listRenameActiveRef.current) return + + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedRowIdsRef.current.size > 0) { + e.preventDefault() + handleBulkDeleteRef.current() + return + } + + if (e.key === 'Escape' && selectedRowIdsRef.current.size > 0) { + e.preventDefault() + setSelectedRowIds(new Set()) + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { + e.preventDefault() + setSelectedRowIds(new Set(visibleRowIdsRef.current)) + } + } + window.addEventListener('keydown', handleListKeyDown) + return () => window.removeEventListener('keydown', handleListKeyDown) + }, []) + const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -807,14 +1427,19 @@ export function Files() { icon: Download, onClick: handleDownloadSelected, }, - { - label: 'Delete', - icon: Trash, - onClick: handleDeleteSelected, - }, + ...(canEdit + ? [ + { + label: 'Delete', + icon: Trash2, + onClick: handleDeleteSelected, + }, + ] + : []), ] }, [ selectedFile, + canEdit, saveStatus, previewMode, isDirty, @@ -831,19 +1456,27 @@ export function Files() { headerRenameRef.current = headerRename const handleRowClick = useCallback( - (id: string) => { - if (listRenameRef.current.editingId !== id && !headerRenameRef.current.editingId) { - router.push(`/workspace/${workspaceId}/files/${id}`) + (rowId: string) => { + if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + router.push(`/workspace/${workspaceId}/files?folderId=${parsed.id}`) + return + } + router.push( + currentFolderId + ? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${currentFolderId}` + : `/workspace/${workspaceId}/files/${parsed.id}` + ) } }, - [router, workspaceId] + [router, workspaceId, currentFolderId] ) const handleUploadClick = useCallback(() => { + if (!canEdit || uploading) return fileInputRef.current?.click() - }, []) - - const canEdit = userPermissions.canEdit === true + }, [canEdit, uploading]) const searchConfig: SearchConfig = { value: inputValue, @@ -873,16 +1506,82 @@ export function Files() { label: uploadButtonLabel, icon: Upload, onClick: handleUploadClick, + disabled: uploading || !canEdit, + }, + { + label: 'New folder', + icon: FolderPlus, + onClick: handleCreateFolder, + disabled: createFolder.isPending || !canEdit, }, ], - [uploadButtonLabel, handleUploadClick] + [uploadButtonLabel, handleUploadClick, handleCreateFolder, createFolder.isPending, canEdit] ) - const handleNavigateToFiles = () => { + const handleNavigateToFiles = useCallback(() => { router.push(`/workspace/${workspaceId}/files`) - } + }, [router, workspaceId]) + + const loadingBreadcrumbs = useMemo( + () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }], + [handleNavigateToFiles] + ) + + const breadcrumbRenameRef = useRef(breadcrumbRename) + breadcrumbRenameRef.current = breadcrumbRename + + const listBreadcrumbs = useMemo(() => { + const breadcrumbs: BreadcrumbItem[] = [{ label: 'Files', onClick: handleNavigateToFiles }] + if (!currentFolderPath) return breadcrumbs - const loadingBreadcrumbs = [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }] + const segments = currentFolderPath.split('/') + let parentId: string | null = null + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const folder = folders.find( + (item) => item.name === segment && (item.parentId ?? null) === parentId + ) + if (!folder) continue + const isCurrentFolder = folder.id === currentFolderId + breadcrumbs.push({ + label: folder.name, + onClick: isCurrentFolder + ? undefined + : () => router.push(`/workspace/${workspaceId}/files?folderId=${folder.id}`), + editing: + isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id + ? { + isEditing: true, + value: breadcrumbRenameRef.current.editValue, + onChange: breadcrumbRenameRef.current.setEditValue, + onSubmit: breadcrumbRenameRef.current.submitRename, + onCancel: breadcrumbRenameRef.current.cancelRename, + } + : undefined, + dropdownItems: + isCurrentFolder && canEdit + ? [ + { + label: 'Rename', + onClick: () => breadcrumbRenameRef.current.startRename(folder.id, folder.name), + }, + ] + : undefined, + }) + parentId = folder.id + } + return breadcrumbs + }, [ + currentFolderPath, + currentFolderId, + folders, + handleNavigateToFiles, + router, + workspaceId, + canEdit, + breadcrumbRename.editingId, + breadcrumbRename.editValue, + ]) const memberOptions: ComboboxOption[] = useMemo( () => @@ -905,6 +1604,22 @@ export function Files() { [members] ) + const contextMenuMoveOptions = useMemo((): MoveOptionNode[] => { + const buildSubtree = (parentId: string | null): MoveOptionNode[] => + folders + .filter((f) => { + if ((f.parentId ?? null) !== parentId) return false + if (selectedFolderIds.includes(f.id)) return false + return selectedFolderIds.every( + (sid) => !descendantFolderIdsByFolderId.get(sid)?.has(f.id) + ) + }) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) + .map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) })) + + return [{ value: '__root__', label: 'Files', children: [] }, ...buildSubtree(null)] + }, [folders, selectedFolderIds, descendantFolderIdsByFolderId]) + const sortConfig: SortConfig = useMemo( () => ({ options: [ @@ -925,6 +1640,14 @@ export function Files() { const hasActiveFilters = typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0 + const emptyMessage = debouncedSearchTerm + ? `No files match "${debouncedSearchTerm}"` + : hasActiveFilters + ? 'No files match the active filters' + : currentFolderId + ? 'This folder is empty' + : 'No files yet' + const filterContent = useMemo(() => { const typeDisplayLabel = typeFilter.length === 0 @@ -1026,13 +1749,12 @@ export function Files() { {hasActiveFilters && ( @@ -1113,9 +1835,9 @@ export function Files() { Unsaved Changes -

+ You have unsaved changes. Are you sure you want to discard them? -

+
) } - -interface FileRowContextMenuProps { - isOpen: boolean - position: { x: number; y: number } - onClose: () => void - onOpen: () => void - onDownload: () => void - onRename: () => void - onDelete: () => void - canEdit: boolean -} - -const FileRowContextMenu = memo(function FileRowContextMenu({ - isOpen, - position, - onClose, - onOpen, - onDownload, - onRename, - onDelete, - canEdit, -}: FileRowContextMenuProps) { - return ( - !open && onClose()} modal={false}> - -
- - e.preventDefault()} - > - - - Open - - - - Download - - {canEdit && ( - <> - - - - Rename - - - - Delete - - - )} - - - ) -}) - -interface DeleteConfirmModalProps { - open: boolean - onOpenChange: (open: boolean) => void - fileName?: string - onDelete: () => void - isPending: boolean -} - -const DeleteConfirmModal = memo(function DeleteConfirmModal({ - open, - onOpenChange, - fileName, - onDelete, - isPending, -}: DeleteConfirmModalProps) { - return ( - - - Delete File - -

- Are you sure you want to delete{' '} - {fileName}? You can - restore it from Recently Deleted in Settings. -

-
- - - - -
-
- ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx new file mode 100644 index 00000000000..3909673616e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@/components/emcn' +import { Folder } from '@/components/emcn/icons' + +export interface MoveOptionNode { + value: string + label: string + children: MoveOptionNode[] +} + +export function renderMoveOption( + option: MoveOptionNode, + onMove: (value: string) => void +): ReactNode { + if (option.children.length === 0) { + return ( + onMove(option.value)}> + + {option.label} + + ) + } + return ( + + + + {option.label} + + + onMove(option.value)}>Move here + + {option.children.map((child) => renderMoveOption(child, onMove))} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx index efcae7e3394..0ba36377e7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx @@ -69,6 +69,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + filefolder: { + label: 'File folder', + renderIcon: ({ className }) => , + }, past_chat: { label: 'Past chat', renderIcon: ({ className }) => , diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index f4ccdd57854..f5ebb862fe6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -31,6 +31,7 @@ import { useLogsList } from '@/hooks/queries/logs' import { useTablesList } from '@/hooks/queries/tables' import { useTasks } from '@/hooks/queries/tasks' import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' export interface AddResourceDropdownProps { @@ -73,6 +74,7 @@ export function useAvailableResources( const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) const { data: folders = [] } = useFolders(workspaceId) + const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId) const { data: tasks = [] } = useTasks(workspaceId) const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS) const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData]) @@ -119,9 +121,19 @@ export function useAvailableResources( items: files.map((f) => ({ id: f.id, name: f.name, + folderId: f.folderId ?? null, isOpen: existingKeys.has(`file:${f.id}`), })), }, + { + type: 'filefolder' as const, + items: fileFolders.map((f) => ({ + id: f.id, + name: f.name, + parentId: f.parentId ?? null, + isOpen: existingKeys.has(`filefolder:${f.id}`), + })), + }, { type: 'knowledgebase' as const, items: (knowledgeBases ?? []).map((kb) => ({ @@ -162,6 +174,7 @@ export function useAvailableResources( }, [ workflows, folders, + fileFolders, tables, files, knowledgeBases, @@ -271,6 +284,91 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI ) } +export type FileFolderTreeNode = + | { kind: 'file'; id: string; name: string; isOpen?: boolean } + | { kind: 'folder'; id: string; name: string; isOpen?: boolean; children: FileFolderTreeNode[] } + +export function buildFileFolderTree( + fileItems: AvailableItem[], + folderItems: AvailableItem[] +): FileFolderTreeNode[] { + const byFolder = new Map() + for (const f of fileItems) { + const key = (f.folderId as string | null | undefined) ?? null + const bucket = byFolder.get(key) ?? [] + bucket.push(f) + byFolder.set(key, bucket) + } + + const buildLevel = (parentId: string | null): FileFolderTreeNode[] => { + const childFolders = folderItems.filter( + (f) => ((f.parentId as string | null | undefined) ?? null) === parentId + ) + const childFiles = byFolder.get(parentId) ?? [] + const nodes: FileFolderTreeNode[] = [] + for (const folder of childFolders) { + const children = buildLevel(folder.id) + nodes.push({ + kind: 'folder', + id: folder.id, + name: folder.name, + isOpen: folder.isOpen, + children, + }) + } + for (const file of childFiles) { + nodes.push({ kind: 'file', id: file.id, name: file.name, isOpen: file.isOpen }) + } + return nodes + } + + return buildLevel(null) +} + +interface FileFolderTreeItemsProps { + nodes: FileFolderTreeNode[] + onSelect: (resource: MothershipResource, isOpen?: boolean) => void +} + +export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProps) { + return ( + <> + {nodes.map((node) => + node.kind === 'file' ? ( + onSelect({ type: 'file', id: node.id, title: node.name }, node.isOpen)} + > + {getResourceConfig('file').renderDropdownItem({ + item: { id: node.id, name: node.name }, + })} + + ) : ( + + + + {node.name} + + + + onSelect({ type: 'filefolder', id: node.id, title: node.name }, node.isOpen) + } + > + + {node.name} + + {node.children.length > 0 && ( + + )} + + + ) + )} + + ) +} + export function AddResourceDropdown({ workspaceId, existingKeys, @@ -282,7 +380,6 @@ export function AddResourceDropdown({ const [search, setSearch] = useState('') const [activeIndex, setActiveIndex] = useState(0) const available = useAvailableResources(workspaceId, existingKeys, excludeTypes) - const handleOpenChange = (next: boolean) => { setOpen(next) if (!next) { @@ -308,6 +405,12 @@ export function AddResourceDropdown({ return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) }, [available]) + const fileFolderTree = useMemo(() => { + const fileGroup = available.find((g) => g.type === 'file') + const fileFolderGroup = available.find((g) => g.type === 'filefolder') + return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? []) + }, [available]) + const filtered = useMemo(() => { const q = search.toLowerCase().trim() if (!q) return null @@ -407,8 +510,28 @@ export function AddResourceDropdown({ )} + {fileFolderTree.length > 0 && ( + + + {(() => { + const Icon = getResourceConfig('file').icon + return + })()} + Files + + + + + + )} {available.map(({ type, items }) => { - if (type === 'workflow' || type === 'folder') return null + if ( + type === 'workflow' || + type === 'folder' || + type === 'file' || + type === 'filefolder' + ) + return null if (items.length === 0) return null const config = getResourceConfig(type) const Icon = config.icon diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts index a89dbf0db7e..8a266363937 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -1,11 +1,14 @@ export type { AddResourceDropdownProps, AvailableItem, + FileFolderTreeNode, WorkflowTreeNode, } from './add-resource-dropdown' export { AddResourceDropdown, + buildFileFolderTree, buildWorkflowFolderTree, + FileFolderTreeItems, useAvailableResources, WorkflowFolderTreeItems, } from './add-resource-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index c80ca1c7b08..73d312c42ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -27,6 +27,7 @@ import { taskKeys } from '@/hooks/queries/tasks' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { useWorkflows } from '@/hooks/queries/workflows' +import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' interface DropdownItemRenderProps { @@ -180,6 +181,15 @@ export const RESOURCE_REGISTRY: Record , }, + filefolder: { + type: 'filefolder', + label: 'File Folders', + icon: FolderIcon, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, task: { type: 'task', label: 'Tasks', @@ -232,6 +242,9 @@ const RESOURCE_INVALIDATORS: Record< folder: (qc) => { qc.invalidateQueries({ queryKey: folderKeys.lists() }) }, + filefolder: (qc, wId) => { + qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) }) + }, task: (qc, wId) => { qc.invalidateQueries({ queryKey: taskKeys.list(wId) }) }, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 4507b0f3e41..7f6a6c76544 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -90,6 +90,7 @@ const RESOURCE_TO_CONTEXT: Record< table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }), file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }), folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }), + filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }), task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }), log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }), generic: (r) => ({ kind: 'docs', label: r.title }), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index 45875b9b05c..dcdaf517ebe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -14,7 +14,9 @@ import { import { Plus } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { + buildFileFolderTree, buildWorkflowFolderTree, + FileFolderTreeItems, type useAvailableResources, WorkflowFolderTreeItems, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' @@ -75,6 +77,12 @@ export const PlusMenuDropdown = React.memo( return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) }, [availableResources]) + const fileFolderTree = useMemo(() => { + const fileGroup = availableResources.find((g) => g.type === 'file') + const fileFolderGroup = availableResources.find((g) => g.type === 'filefolder') + return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? []) + }, [availableResources]) + const filteredItems = useMemo(() => { const rawQuery = isMention ? (mentionQuery ?? '') : search const q = rawQuery.toLowerCase().trim() @@ -293,8 +301,28 @@ export const PlusMenuDropdown = React.memo( )} + {fileFolderTree.length > 0 && ( + + + {(() => { + const Icon = getResourceConfig('file').icon + return + })()} + Files + + + + + + )} {availableResources - .filter(({ type }) => type !== 'workflow' && type !== 'folder') + .filter( + ({ type }) => + type !== 'workflow' && + type !== 'folder' && + type !== 'file' && + type !== 'filefolder' + ) .map(({ type, items }) => { if (items.length === 0) return null const config = getResourceConfig(type) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx index aa30ce5761f..bd10d154ee5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx @@ -1,6 +1,14 @@ 'use client' -import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, +} from '@/components/emcn' import type { ChunkData } from '@/lib/knowledge/types' import { useDeleteChunk } from '@/hooks/queries/kb/knowledge' @@ -34,9 +42,9 @@ export function DeleteChunkModal({ Delete Chunk -

+ Are you sure you want to delete this chunk? This action cannot be undone. -

+
+ + View and edit tags assigned to this document +
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 22b22551a29..e7e38119d63 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -11,6 +11,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Trash, @@ -72,9 +73,9 @@ function UnsavedChangesModal({ Unsaved Changes -

+ You have unsaved changes. Are you sure you want to discard them? -

+