diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 1f9d1b55a6b..01f30f3e399 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1365,25 +1365,30 @@ export const S3Icon = (props: SVGProps) => ( ) +const GOOGLE_ICON_PNG_DATA_URI = + 'data:image/png;base64,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' + +/** + * Google's current "G" mark. + * + * Google publishes this logo only as a raster asset, so the official 200x204 transparent PNG is + * embedded verbatim inside an SVG wrapper to keep the shared `SVGProps` icon API. + * The wrapper's viewBox matches the artwork exactly, which bleeds to all four edges. + * + * @see https://developers.google.com/identity/branding-guidelines + * @see https://developers.google.com/static/identity/images/g-logo.png + */ export function GoogleIcon(props: SVGProps) { return ( - - - - - + + ) } diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index 77cf0ca74c2..65ee0f24985 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -239,6 +239,7 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive ` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index 3208568d6cc..a0f59c28f6d 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -741,6 +741,7 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive ` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 122cc4f3e99..c0e6b736aa1 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -313,6 +313,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -323,16 +329,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -343,6 +345,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -353,61 +364,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -423,16 +385,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -443,6 +404,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -458,6 +425,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index f2e459d88fa..f5fa23ada8b 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -59,12 +59,36 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, { "name": "scope", "in": "query", @@ -2102,6 +2126,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2112,16 +2142,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2132,6 +2158,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2142,6 +2177,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2152,21 +2193,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "File already exists" + } } } } @@ -2177,26 +2209,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2212,16 +2230,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2232,6 +2249,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2247,6 +2270,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index a1b2d4d6eaf..401eb99a249 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2047,6 +2047,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2057,6 +2063,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2067,6 +2079,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } } } } @@ -2077,6 +2095,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2087,6 +2114,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2097,21 +2130,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Upload has already been completed" + } } } } @@ -2122,6 +2146,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2132,16 +2162,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } } } } @@ -2157,16 +2183,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2177,6 +2202,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2192,6 +2223,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index cc4cfd86202..af7bfe5e2dd 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -435,6 +435,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -445,16 +451,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -465,6 +467,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -475,61 +486,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -545,16 +507,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -565,6 +526,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -580,6 +547,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 8caf0604abb..f34b36a578e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2426,6 +2426,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2436,16 +2442,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2456,6 +2458,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2466,6 +2477,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2476,21 +2493,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "API key name already exists" + } } } } @@ -2501,26 +2509,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2536,16 +2530,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2556,6 +2549,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2571,6 +2570,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index c4a6b1ce6fd..877c8b329ce 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3821,6 +3821,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -3831,16 +3837,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -3851,6 +3853,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -3861,6 +3872,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -3871,21 +3888,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A table named \"Orders\" already exists in this workspace" + } } } } @@ -3896,16 +3904,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -3916,6 +3920,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "This table is insert-locked: new rows cannot be added.", + "details": { + "lock": "insert" + } + } } } } @@ -3931,16 +3944,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -3951,6 +3963,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -3966,6 +3984,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 541863f37d3..6d43438c88b 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2129,6 +2129,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2139,6 +2145,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2149,6 +2161,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } } } } @@ -2159,6 +2177,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2169,6 +2196,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2179,6 +2212,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Webhook path already in use" + } } } } @@ -2194,6 +2233,16 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Run ID has already been used", + "details": { + "code": "RUN_ID_CONFLICT", + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } } } } @@ -2204,16 +2253,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2224,6 +2269,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked" + } } } } @@ -2239,6 +2290,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2249,6 +2309,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CLIENT_CLOSED_REQUEST", + "message": "Client cancelled request", + "details": { + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } } } } @@ -2259,6 +2328,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2274,6 +2349,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx index 296f0a01318..1a605b3ddb6 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx @@ -5,7 +5,7 @@ import { ISO_FILL_LOW, ISO_FILL_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoBuildIllustrationProps { size?: number @@ -14,7 +14,7 @@ export interface IsoBuildIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-build-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-build-line') /** Isometric tile module the floor grid and the columns are built on. */ const TILE_WIDTH = 38 diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx index 9411b4b3fa9..92289a3972d 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx @@ -9,7 +9,7 @@ import { ISO_FILL_PULSE_LOW, ISO_FILL_PULSE_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoIngestIllustrationProps { size?: number @@ -18,7 +18,7 @@ export interface IsoIngestIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-ingest-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-ingest-line') /** * Inline supplied illustration for the Context area - a central store diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx index 955fca0f61e..51f78761cd2 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx @@ -5,7 +5,7 @@ import { ISO_FILL_LOW, ISO_FILL_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoIntegrateIllustrationProps { size?: number @@ -14,7 +14,7 @@ export interface IsoIntegrateIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-integrate-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-integrate-line') /** * Inline supplied illustration for the Integrate area - a three-tier isometric diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx index dd7643d14d3..fb0b921308c 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx @@ -5,7 +5,7 @@ import { ISO_FILL_LOW, ISO_FILL_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoMonitorIllustrationProps { size?: number @@ -14,7 +14,7 @@ export interface IsoMonitorIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-monitor-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-monitor-line') /** * Inline supplied illustration for the Monitor area - an isometric housing whose diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index e544f4bf231..4f38ee453fb 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -1,4 +1,5 @@ import { + listsSubfolders, type V2File, v2CreateFileContract, v2ListFilesContract, @@ -23,12 +24,19 @@ function fileCursorFilters(query: { scope?: string folderPath?: string search?: string + recursive?: boolean }) { return cursorScopeKey(cursorRoute(v2ListFilesContract), { workspaceId: query.workspaceId, scope: query.scope, folderPath: query.folderPath, search: query.search, + /** + * Keyed on the resolved value, not the raw parameter: omitting `recursive` beside a + * search asks for the same page as sending `recursive=true`, so keying on the parameter + * would reject a cursor between two requests that select identical rows. + */ + recursive: String(listsSubfolders(query)), }) } @@ -44,6 +52,7 @@ export const GET = defineV2JsonRoute({ scope: query.scope, folderPath: query.folderPath, search: query.search, + recursive: listsSubfolders(query), sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index c96763ac4e9..4351f193aec 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,5 +1,10 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' +import { + V2_ERROR_CODE_BY_STATUS, + V2_ERROR_STATUS_BY_CODE, + type V2ErrorCode, +} from '@/lib/api/contracts/v2/error-codes' import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' @@ -21,41 +26,6 @@ import type { RateLimitResult } from '@/app/api/v1/middleware' * the HTTP envelope. */ -export type V2ErrorCode = - | 'BAD_REQUEST' - | 'UNAUTHORIZED' - | 'FORBIDDEN' - | 'NOT_FOUND' - | 'CONFLICT' - | 'PAYLOAD_TOO_LARGE' - | 'UNSUPPORTED_MEDIA_TYPE' - | 'USAGE_LIMIT_EXCEEDED' - | 'LOCKED' - | 'RATE_LIMITED' - | 'CLIENT_CLOSED_REQUEST' - | 'INTERNAL_ERROR' - | 'SERVICE_UNAVAILABLE' - -const STATUS_BY_CODE: Record = { - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - USAGE_LIMIT_EXCEEDED: 402, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - PAYLOAD_TOO_LARGE: 413, - UNSUPPORTED_MEDIA_TYPE: 415, - LOCKED: 423, - RATE_LIMITED: 429, - CLIENT_CLOSED_REQUEST: 499, - INTERNAL_ERROR: 500, - SERVICE_UNAVAILABLE: 503, -} - -const V2_CODE_BY_HTTP_STATUS: Partial> = Object.fromEntries( - Object.entries(STATUS_BY_CODE).map(([code, status]) => [status, code as V2ErrorCode]) -) - /** * Every v2 response is authed, per-caller data (ids/filters appear in query * strings) — keep it out of shared HTTP caches unconditionally. @@ -195,7 +165,7 @@ export function v2Error( ): NextResponse { const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } if (options.details !== undefined) error.details = options.details - const status = options.status ?? STATUS_BY_CODE[code] + const status = options.status ?? V2_ERROR_STATUS_BY_CODE[code] const retryAfterSeconds = options.omitRetryAfter ? undefined : RETRY_AFTER_SECONDS_BY_STATUS[status] @@ -215,7 +185,7 @@ export function v2Error( /** Renders a trusted typed HTTP error without changing the v2 envelope. */ export function v2HttpError(error: HttpError): NextResponse { - const code = V2_CODE_BY_HTTP_STATUS[error.statusCode] + const code = V2_ERROR_CODE_BY_STATUS[error.statusCode] if (!code) return v2Error('INTERNAL_ERROR', 'Internal server error') return v2Error(code, error.message) } diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 1ac2c88dbec..002fa26b1c3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -5,6 +5,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes' import { V2_WORKFLOW_RUN_ID_HEADER, v2ExecuteWorkflowContract, @@ -45,7 +46,7 @@ import { hasAgentStreamPolicy, } from '@/lib/workflows/streaming/agent-stream-protocol' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' import { PublicApiNotAllowedError, validatePublicApiAllowed, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index d3e3410117e..1b5b87070e9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' +import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes' import { V2_WORKFLOW_RUN_ID_HEADER, v2ResumeWorkflowContract, @@ -19,7 +20,7 @@ import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { workflowOperations } from '@/lib/workflows/application/operations' import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run' import { ResumeWorkflowExecutionError } from '@/lib/workflows/executor/resume-execution' -import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' import { classifyExecutionError } from '@/executor/utils/errors' const logger = createLogger('V2WorkflowResumeAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-move-pruning.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-move-pruning.test.ts new file mode 100644 index 00000000000..d21b86dae56 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-move-pruning.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { dropRowsCarriedByDraggedFolders } from '@/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop' + +/** + * reports/ (a) + * └── 2024/ (b) + * └── q3/ (c) + * archive/ (d) + */ +const PARENT_BY_FOLDER: Record = { a: null, b: 'a', c: 'b', d: null } +const FOLDER_BY_RESOURCE: Record = { + 'file-in-a': 'a', + 'file-in-c': 'c', + 'file-in-d': 'd', + 'file-at-root': null, +} + +const DESCENDANTS = new Map>([ + ['a', new Set(['b', 'c'])], + ['b', new Set(['c'])], + ['c', new Set()], + ['d', new Set()], +]) + +const accessors = { + descendantsByFolderId: DESCENDANTS, + getFolderParentId: (id: string) => PARENT_BY_FOLDER[id], + getResourceFolderId: (id: string) => FOLDER_BY_RESOURCE[id], +} + +const prune = (folderIds: string[], resourceIds: string[]) => + dropRowsCarriedByDraggedFolders({ folderIds, resourceIds }, accessors) + +describe('dropRowsCarriedByDraggedFolders', () => { + it('leaves a move with no folders untouched', () => { + expect(prune([], ['file-in-a', 'file-in-d'])).toEqual({ + folderIds: [], + resourceIds: ['file-in-a', 'file-in-d'], + }) + }) + + it('drops a file that the dragged folder directly contains', () => { + expect(prune(['a'], ['file-in-a', 'file-in-d'])).toEqual({ + folderIds: ['a'], + resourceIds: ['file-in-d'], + }) + }) + + it('drops a file nested deeper inside the dragged folder', () => { + expect(prune(['a'], ['file-in-c'])).toEqual({ folderIds: ['a'], resourceIds: [] }) + }) + + it('drops a descendant folder dragged alongside its ancestor', () => { + expect(prune(['a', 'c'], [])).toEqual({ folderIds: ['a'], resourceIds: [] }) + expect(prune(['a', 'b', 'c'], [])).toEqual({ folderIds: ['a'], resourceIds: [] }) + }) + + it('keeps unrelated folders and root-level files', () => { + expect(prune(['a', 'd'], ['file-at-root'])).toEqual({ + folderIds: ['a', 'd'], + resourceIds: ['file-at-root'], + }) + }) + + it('never drops the only dragged folder', () => { + expect(prune(['c'], [])).toEqual({ folderIds: ['c'], resourceIds: [] }) + }) + + it('can empty the move entirely when every row rides along', () => { + expect(prune(['a'], ['file-in-a'])).toEqual({ folderIds: ['a'], resourceIds: [] }) + expect(prune(['a', 'b'], ['file-in-c'])).toEqual({ folderIds: ['a'], resourceIds: [] }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.test.ts new file mode 100644 index 00000000000..ef411851586 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + folderLocationLabel, + isSearchingResources, + scopeFolderedItems, +} from '@/app/workspace/[workspaceId]/components/folders/folder-search-scope' + +interface Item { + id: string + name: string + description?: string | null + folderId: string | null +} + +const ITEMS: Item[] = [ + { id: 'root-report', name: 'report.pdf', folderId: null }, + { id: 'a-report', name: 'report.pdf', folderId: 'a' }, + { id: 'b-budget', name: 'budget.xlsx', folderId: 'b' }, + { id: 'a-notes', name: 'notes.md', description: 'quarterly report', folderId: 'a' }, +] + +const scope = (currentFolderId: string | null, search: string) => + scopeFolderedItems(ITEMS, { + currentFolderId, + search, + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.name], + }).map((item) => item.id) + +describe('scopeFolderedItems', () => { + it('shows only direct children when there is no query', () => { + expect(scope(null, '')).toEqual(['root-report']) + expect(scope('a', '')).toEqual(['a-report', 'a-notes']) + }) + + it('treats a whitespace-only query as no query', () => { + expect(scope('a', ' ')).toEqual(['a-report', 'a-notes']) + }) + + it('searches every folder, not just the open one', () => { + expect(scope('b', 'report')).toEqual(['root-report', 'a-report']) + }) + + it('finds nested matches from the workspace root', () => { + expect(scope(null, 'budget')).toEqual(['b-budget']) + }) + + it('matches case-insensitively', () => { + expect(scope(null, 'BUDGET')).toEqual(['b-budget']) + }) + + it('matches any of several fields independently', () => { + const ids = scopeFolderedItems(ITEMS, { + currentFolderId: null, + search: 'quarterly', + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.name, item.description], + }).map((item) => item.id) + expect(ids).toEqual(['a-notes']) + }) + + it('never lets a query straddle two fields', () => { + const ids = scopeFolderedItems([{ id: 'x', name: 'ab', description: 'cd', folderId: null }], { + currentFolderId: null, + search: 'bc', + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.name, item.description], + }) + expect(ids).toEqual([]) + }) + + it('tolerates absent fields', () => { + const ids = scopeFolderedItems(ITEMS, { + currentFolderId: null, + search: 'report', + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.description], + }).map((item) => item.id) + expect(ids).toEqual(['a-notes']) + }) +}) + +describe('isSearchingResources', () => { + it('ignores whitespace-only queries', () => { + expect(isSearchingResources('')).toBe(false) + expect(isSearchingResources(' ')).toBe(false) + expect(isSearchingResources('a')).toBe(true) + }) +}) + +describe('folderLocationLabel', () => { + const folders = new Map([ + ['a', { id: 'a', name: 'Projects', parentId: null }], + ['b', { id: 'b', name: 'Q3', parentId: 'a' }], + ['orphan', { id: 'orphan', name: 'Lost', parentId: 'gone' }], + ]) + + it('names the root when the item sits at the root', () => { + expect(folderLocationLabel(null, folders, 'Files')).toBe('Files') + expect(folderLocationLabel(undefined, folders, 'Files')).toBe('Files') + }) + + it('joins the ancestor chain root-first', () => { + expect(folderLocationLabel('a', folders, 'Files')).toBe('Projects') + expect(folderLocationLabel('b', folders, 'Files')).toBe('Projects / Q3') + }) + + it('says it does not know rather than claiming a partial path or the root', () => { + expect(folderLocationLabel('orphan', folders, 'Files')).toBe('Unknown') + expect(folderLocationLabel('missing', folders, 'Files')).toBe('Unknown') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.ts new file mode 100644 index 00000000000..8ede6b91fa8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.ts @@ -0,0 +1,111 @@ +import type { BreadcrumbFolder } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' +import { breadcrumbFolderChain } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' +import type { + ResourceCell, + ResourceColumn, +} from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** + * Whether the list is showing search results rather than a folder's contents. + * + * The distinction drives more than the filter: while searching, a row's folder is no + * longer implied by the page, so the list has to say where each row lives, and an empty + * result is a failed search rather than an empty place. + */ +export function isSearchingResources(search: string): boolean { + return search.trim().length > 0 +} + +/** + * The rows a foldered list should show. + * + * With no query the list is a place: the open folder's direct children, and nothing else. + * With a query it stops being a place and searches the whole workspace, because a name you + * only half-remember is precisely the case where you do not know which folder it is in. + * Intersecting the two — matching the query only against the open folder — answers a + * question nobody asks, and is indistinguishable from "no such file" when the file exists + * one level down. + * + * Callers pass an already-debounced query; this runs on every keystroke's worth of state. + */ +export function scopeFolderedItems( + items: readonly T[], + { + currentFolderId, + search, + getParentId, + getSearchText, + }: { + /** The open folder, or `null` at the workspace root. */ + currentFolderId: string | null + search: string + getParentId: (item: T) => string | null + /** + * The item's searchable fields. Matched one at a time rather than joined, so a query can + * never straddle two of them — concatenating a name and a description would let the tail + * of one and the head of the other match text that appears nowhere. + */ + getSearchText: (item: T) => readonly (string | null | undefined)[] + } +): T[] { + if (!isSearchingResources(search)) { + return items.filter((item) => getParentId(item) === currentFolderId) + } + const needle = search.trim().toLowerCase() + return items.filter((item) => + getSearchText(item).some((field) => field?.toLowerCase().includes(needle)) + ) +} + +/** + * What the location column shows when a row's folder cannot be resolved to a full path — + * its ancestor chain is broken, so the honest answer is that we do not know where it is. + * + * Deliberately not `rootLabel`: this column exists to answer "where does this row live", and + * naming the workspace root there is a specific wrong answer rather than a missing one. The + * root case is a row with no folder at all, which is genuinely at the root. + */ +export const UNKNOWN_FOLDER_LOCATION = 'Unknown' + +/** + * Where a row lives, for the list's location column — ancestor names root-first joined by + * `/`, or `rootLabel` for a row sitting at the workspace root. + * + * A chain that does not reach the root yields {@link UNKNOWN_FOLDER_LOCATION}, matching + * {@link breadcrumbFolderChain}'s own rule that a partial path is not a shorter path, it is + * a wrong one. Reachable when an ancestor folder was archived out from under the row. + */ +export function folderLocationLabel( + folderId: string | null | undefined, + folderById: ReadonlyMap, + rootLabel: string +): string { + if (!folderId) return rootLabel + const chain = breadcrumbFolderChain(folderId, folderById) + return chain.length > 0 ? chain.map((folder) => folder.name).join(' / ') : UNKNOWN_FOLDER_LOCATION +} + +/** + * The column naming each row's folder, carried only while searching: results span every + * folder, so a name alone no longer says where a row lives. When the list is a folder's + * contents the breadcrumb already says it. + * + * Defined here rather than per page so one conceptual column has one header and one width, + * and beside {@link folderLocationLabel} because that is what fills it. Each page appends it + * to its own columns at module scope, so the table swaps between two stable arrays rather + * than building one per render. + */ +export const FOLDER_LOCATION_COLUMN: ResourceColumn = { + id: 'location', + header: 'Location', + widthMultiplier: 1.1, +} + +/** + * The location cell a row carries while the list is not searching. + * + * {@link FOLDER_LOCATION_COLUMN} is absent then, so nothing renders this — which is the + * point: resolving an ancestor chain per row to fill a column that is not on screen is work + * every row throws away. Shared and empty so the skipped cell still has a stable identity. + */ +export const EMPTY_LOCATION_CELL: ResourceCell = {} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index 20c6a275243..93f2ecf13f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -7,6 +7,13 @@ export type { FolderRowOptions } from './folder-row' export { folderRow } from './folder-row' export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' export { folderRowId, parseFolderedRowId, splitFolderedRowIds } from './folder-row-id' +export { + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, + folderLocationLabel, + isSearchingResources, + scopeFolderedItems, +} from './folder-search-scope' export type { FolderedHeaderResourceType, FolderedResourceHeaderMeta, diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index a739111c83a..ccd3822ea12 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useQueryStates } from 'nuqs' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' import { @@ -15,17 +15,36 @@ import { export interface UseFolderNavigationOptions { resourceType: ServedFolderResourceType workspaceId?: string + /** + * Runs before {@link FolderNavigation.openFolder} moves, for state the destination + * invalidates — in practice, clearing the list's search. Not run by + * {@link FolderNavigation.setCurrentFolderId}. + */ + onBeforeOpenFolder?: () => void } export interface FolderNavigation extends FolderAncestors { /** The open folder, or `null` at the workspace root. */ currentFolderId: string | null /** - * Opens a folder. Defaults to the param group's `history: 'push'` — a folder the user chose - * to open is a destination. Pass `{ history: 'replace' }` for a write that is not a chosen - * navigation, such as the second and later spring-opens within a single drag. + * Moves to a folder without side effects. For writes that are not a chosen navigation — + * a spring-open mid-drag, which is undone when the drag ends without a drop, or the heal + * below. Pass `{ history: 'replace' }` to keep such a write out of the back stack. */ setCurrentFolderId: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void + /** + * Opens a folder because the user chose to, running {@link + * UseFolderNavigationOptions.onBeforeOpenFolder} first. + * + * Separate from {@link FolderNavigation.setCurrentFolderId} because opening a folder ends a + * search — the results span every folder, so the one the user picked out of them is a + * destination, not a narrower place to keep searching — while a spring-open must not, or an + * abandoned drag would discard the search that produced the row being dragged. + * + * Defaults to the param group's `history: 'push'`: a chosen folder is a destination, and + * Back returns to the results that led there. + */ + openFolder: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void } /** @@ -40,6 +59,7 @@ export interface FolderNavigation extends FolderAncestors { export function useFolderNavigation({ resourceType, workspaceId, + onBeforeOpenFolder, }: UseFolderNavigationOptions): FolderNavigation { const [{ folderId: currentFolderId }, setFolderParams] = useQueryStates( folderNavParsers, @@ -60,6 +80,22 @@ export function useFolderNavigation({ [setFolderParams] ) + const onBeforeOpenFolderRef = useRef(onBeforeOpenFolder) + onBeforeOpenFolderRef.current = onBeforeOpenFolder + + /** + * Both writes land in one URL update: nuqs batches same-tick writes across param groups and + * escalates the batch to `push` when any of them pushes, so clearing a `history: 'replace'` + * search alongside the folder change stays a single history entry. + */ + const openFolder = useCallback( + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + onBeforeOpenFolderRef.current?.() + void setFolderParams({ folderId }, options) + }, + [setFolderParams] + ) + /** * Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a * link from someone whose workspace it was not. @@ -84,5 +120,5 @@ export function useFolderNavigation({ void setFolderParams({ folderId: null }, { history: 'replace' }) }, [foldersResolved, currentFolderId, folderById, setFolderParams]) - return { ...ancestry, currentFolderId, setCurrentFolderId } + return { ...ancestry, currentFolderId, setCurrentFolderId, openFolder } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index 4ac69bb78b8..61c981dc349 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -72,6 +72,47 @@ export interface FolderedRowMove { resourceIds: string[] } +/** + * Drops every row that one of the dragged folders already carries. + * + * Moving a folder takes its contents with it, so naming both a folder and something inside it + * would move the parent AND separately pull the child out of it: the two land as siblings and + * the hierarchy the user dragged is gone. The folder wins, because it is the thing they + * grabbed the outside of. + * + * Only reachable since search began returning rows from across the workspace — a list showing + * one folder's direct children can never show a row alongside its own ancestor. + */ +export function dropRowsCarriedByDraggedFolders( + move: FolderedRowMove, + { + descendantsByFolderId, + getFolderParentId, + getResourceFolderId, + }: { + descendantsByFolderId: Map> + getFolderParentId: (folderId: string) => string | null | undefined + getResourceFolderId: (resourceId: string) => string | null | undefined + } +): FolderedRowMove { + const draggedFolderIds = new Set(move.folderIds) + if (draggedFolderIds.size === 0) return move + + const isCarried = (ownerFolderId: string | null | undefined): boolean => { + if (!ownerFolderId) return false + for (const folderId of draggedFolderIds) { + if (ownerFolderId === folderId) return true + if (descendantsByFolderId.get(folderId)?.has(ownerFolderId)) return true + } + return false + } + + return { + folderIds: move.folderIds.filter((id) => !isCarried(getFolderParentId(id))), + resourceIds: move.resourceIds.filter((id) => !isCarried(getResourceFolderId(id))), + } +} + export interface UseFolderRowDragDropOptions { /** * This list's private drag MIME. Each surface owns one so a drag started in another list is @@ -121,6 +162,21 @@ export interface UseFolderRowDragDropOptions { * empty folder, which has no row to drop on. */ currentFolderId?: string | null + /** + * The folder the list body drops into, or `undefined` when the body does not stand for a + * folder at all and must decline. + * + * It differs from {@link currentFolderId} whenever the visible rows are not that folder's + * contents. Dropping on the blank area below the rows would then file a row into a folder + * the drop UI never names — harmless when every row already lives there (the target + * declines the no-op), destructive when they do not. A row drop is unaffected: that target + * names its own destination. + * + * Required, and deliberately without a default: a destructuring default fires on an + * explicit `undefined` too, so `= currentFolderId` would silently swallow the very value a + * searching caller passes to decline and leave the guard unreachable. + */ + bodyDropFolderId: string | null | undefined /** * OS file drops, which Files accepts and the other lists do not. * @@ -157,6 +213,7 @@ export function useFolderRowDragDrop({ selection, onSpringOpenFolder, currentFolderId = null, + bodyDropFolderId, externalDrop, }: UseFolderRowDragDropOptions): FolderRowDragDrop { const [activeDropTarget, setActiveDropTarget] = useState(null) @@ -192,6 +249,9 @@ export function useFolderRowDragDrop({ const currentFolderIdRef = useRef(currentFolderId) currentFolderIdRef.current = currentFolderId + const bodyDropFolderIdRef = useRef(bodyDropFolderId) + bodyDropFolderIdRef.current = bodyDropFolderId + const dragGhost = useRowDragGhost() /** Returns the list to its resting state once a drag is over, however it ended. */ @@ -207,9 +267,10 @@ export function useFolderRowDragDrop({ /** * Splits the drag into the rows that would actually move into `targetFolderId`, dropping any - * row already sitting directly there. `null` when the drop is illegal outright — the target is - * one of the dragged folders or inside one, which would orphan a subtree into itself — or when - * nothing would actually change. + * row already sitting directly there and any row a dragged folder already carries (see + * {@link dropRowsCarriedByDraggedFolders}). `null` when the drop is illegal outright — the + * target is one of the dragged folders or inside one, which would orphan a subtree into + * itself — or when nothing would actually change. * * Takes a folder id rather than a row id because the destination is not always a row: the * list body files into the folder currently open, which has no row of its own, and `null` @@ -235,8 +296,12 @@ export function useFolderRowDragDrop({ resourceIds.push(source.id) } - if (folderIds.length === 0 && resourceIds.length === 0) return null - return { folderIds, resourceIds } + const moved = dropRowsCarriedByDraggedFolders( + { folderIds, resourceIds }, + { descendantsByFolderId, getFolderParentId, getResourceFolderId } + ) + if (moved.folderIds.length === 0 && moved.resourceIds.length === 0) return null + return moved }, [] ) @@ -437,9 +502,11 @@ export function useFolderRowDragDrop({ * early return would leave the body overlay showing from the previous folder. Setting * the same value repeatedly is free — React bails on an unchanged state write. */ + const targetFolderId = bodyDropFolderIdRef.current const canDrop = + targetFolderId !== undefined && sourceRowIds.length > 0 && - resolveMoveToFolder(currentFolderIdRef.current, sourceRowIds) !== null + resolveMoveToFolder(targetFolderId, sourceRowIds) !== null setActiveDropTarget((current) => canDrop ? armDropTarget(current, { kind: 'body' }) : null ) @@ -454,17 +521,19 @@ export function useFolderRowDragDrop({ }, onDrop: (e: DragEvent) => { if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return - e.preventDefault() - e.stopPropagation() - const sourceRowIds = - readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current /** * Read from the ref, not the closure. This config is memoized, and during a drag the * only dep that routinely changes is the hovered row — so after a spring-open into an - * empty folder, which has no rows to hover, a captured `currentFolderId` would still - * name the folder the drag came FROM and file the rows back into it. + * empty folder, which has no rows to hover, a captured folder id would still name + * the folder the drag came FROM and file the rows back into it. */ - const targetFolderId = currentFolderIdRef.current + const targetFolderId = bodyDropFolderIdRef.current + /** The body never armed, so a release here is a miss rather than a move. */ + if (targetFolderId === undefined) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current const move = sourceRowIds.length > 0 ? resolveMoveToFolder(targetFolderId, sourceRowIds) : null if (move) springNav.markDropHandled() diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 3aeaaeebbeb..2c16cc15fb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,3 +1,8 @@ +export { + isResourceListEmpty, + resourceListState, +} from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' +export { ResourceNotFound } from '@/app/workspace/[workspaceId]/components/resource/resource-not-found' export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx new file mode 100644 index 00000000000..f0d47606e7f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx @@ -0,0 +1,21 @@ +import { ChipLink } from '@sim/emcn' +import { BookOpen } from '@sim/emcn/icons' + +interface EmptyStateDocsLinkProps { + href: string +} + +/** The docs chip every resource empty state carries, so the four stay identical. */ +export function EmptyStateDocsLink({ href }: EmptyStateDocsLinkProps) { + return ( + + Docs + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state.tsx new file mode 100644 index 00000000000..d94d5337eac --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state.tsx @@ -0,0 +1,140 @@ +import { Chip, cn } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +const KNOWLEDGE_DOCS_URL = 'https://docs.sim.ai/knowledgebase' + +/** + * Ruled text — see the `INK` note in `tables-empty-state.tsx` for why not the surface + * ramp. The heading line takes the stronger mix so the sheet reads as a document + * rather than as blank ruling. + */ +const INK = { + heading: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + body: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', +} as const + +/** Widths of the ruled body lines, in viewBox units. */ +const BODY_LINES = [ + { y: 84, width: 62 }, + { y: 98, width: 54 }, + { y: 112, width: 66 }, +] as const + +/** + * The front sheet, with the top-right corner turned back. + * + * The dog-ear is the one signifier no other graphic in the set uses — the folder is a + * container, the knowledge mark is a shelf of volumes, and this has to read as the + * pages inside one of them. + */ +const SHEET = [ + 'M 58 34', + 'H 128', + 'L 148 54', + 'V 140', + 'Q 148 146 142 146', + 'H 58', + 'Q 52 146 52 140', + 'V 40', + 'Q 52 34 58 34', + 'Z', +].join(' ') + +/** The turned-back corner itself, drawn over the sheet so its two edges both read. */ +const DOG_EAR = ['M 128 34', 'L 148 54', 'H 134', 'Q 128 54 128 48', 'Z'].join(' ') + +/** + * Dissolves upward, the direction the stack recedes — the same call the folder makes. + * The sheets behind are a repeating structure and cost nothing cropped, while the front + * sheet stays crisp where the copy begins beneath it. + */ +const STACK_FADE = + '[-webkit-mask-image:linear-gradient(to_top,#000_56%,transparent_100%)] [mask-image:linear-gradient(to_top,#000_56%,transparent_100%)]' + +/** + * The artwork's own bounds, so the frame follows the drawing rather than a round number. + * + * The sheets are authored around the front sheet's origin and step up and to the right, + * which leaves the drawn mass off-centre inside any round-numbered viewBox — enough that + * the mark sat visibly right of the copy beneath it. Framing on these bounds centres the + * two. They are read off the shapes below by hand, so retuning the stack means updating + * them here. + */ +const ART = { minX: 52, maxX: 166, minY: 18, maxY: 146 } as const +const ART_PADDING = 10 +const VIEW_BOX_WIDTH = ART.maxX - ART.minX + ART_PADDING * 2 +const VIEW_BOX_HEIGHT = ART.maxY - ART.minY + ART_PADDING * 2 +const VIEW_BOX = `${ART.minX - ART_PADDING} ${ART.minY - ART_PADDING} ${VIEW_BOX_WIDTH} ${VIEW_BOX_HEIGHT}` + +/** Matches the other resource graphics, so the set centres as one collection. */ +const MARK_HEIGHT = 148 + +/** + * A stack of sheets, the front one dog-eared and ruled. The two behind it step up and + * to the right, so the stack reads as depth rather than as a single thick page. + */ +function DocumentsGraphic() { + return ( + + ) +} + +interface DocumentsEmptyStateProps { + /** Opens the file picker — the same action the header's primary chip runs. */ + onAddDocuments: () => void + /** Mirrors the header chip's disabled state: no edit rights on the workspace. */ + addDisabled?: boolean +} + +/** Empty state for a knowledge base that holds no documents yet. */ +export function DocumentsEmptyState({ + onAddDocuments, + addDisabled = false, +}: DocumentsEmptyStateProps) { + return ( + } + title='Documents' + description='Upload documents so your agents can search this base.' + action={ + <> + + New documents + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx new file mode 100644 index 00000000000..4fb2d63be38 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx @@ -0,0 +1,91 @@ +import { Chip, cn } from '@sim/emcn' +import { Upload } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +const FILES_DOCS_URL = 'https://docs.sim.ai/files' + +const FOLDER_BACK = [ + 'M 22 34', + 'H 66', + 'Q 72 34 75.5 38', + 'L 82.5 46', + 'Q 86 50 92 50', + 'H 178', + 'Q 188 50 188 60', + 'V 140', + 'Q 188 150 178 150', + 'H 22', + 'Q 12 150 12 140', + 'V 44', + 'Q 12 34 22 34', + 'Z', +].join(' ') + +/** + * Dissolves upward, so the folder rises out of the page while its front panel + * stays crisp where the copy begins. Running it the other way — the direction the + * logs feed fades — ate the panel's base and left the tab hanging. + */ +const FOLDER_FADE = + '[-webkit-mask-image:linear-gradient(to_top,#000_58%,transparent_100%)] [mask-image:linear-gradient(to_top,#000_58%,transparent_100%)]' + +/** + * A folder held open with sheets standing proud of its front panel. + * + * Depth is carried by the surface ramp rather than by shadow: the back panel sits a + * tier down on `--surface-4`, everything in front of it on `--surface-2`. Shadows + * would need separate light and dark recipes; the ramp inverts on its own. + * + * Unlike the tables grid — where a skeleton bar has no outline and so needs mid-grey + * ink to survive a light page — the hairline draws this shape, so the fills only have + * to separate one layer from the next and a near-white tier carries it. + */ +function FilesGraphic() { + return ( + + ) +} + +interface FilesEmptyStateProps { + /** Opens the file picker — the same action the header's upload chip runs. */ + onUpload: () => void + /** Mirrors the header chip's disabled state: no edit rights, or an upload in flight. */ + uploadDisabled?: boolean +} + +/** Empty state for the files list when the workspace has none. */ +export function FilesEmptyState({ onUpload, uploadDisabled = false }: FilesEmptyStateProps) { + return ( + } + title='Files' + description='Upload files to share them across your team and every agent.' + action={ + <> + + Upload + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline.ts new file mode 100644 index 00000000000..6047c31ba94 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline.ts @@ -0,0 +1,15 @@ +/** + * The contour weight every drawn resource graphic shares. + * + * Matches the tables grid's 1px `--border` rules and the knowledge mark's thinned + * strokes — the graphics sit a nav level apart, so a heavier outline on any one of + * them reads as a different illustration system. + * + * Fills stay near the top of the surface ramp for the same reason: the border draws + * the shape and the fill only has to separate one layer from the next. + */ +export const HAIRLINE = { + stroke: 'var(--border)', + strokeWidth: 1.1, + strokeLinejoin: 'round' as const, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts new file mode 100644 index 00000000000..e7d9ddd4474 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -0,0 +1,6 @@ +export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state' +export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' +export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' +export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' +export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results' +export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx new file mode 100644 index 00000000000..dcfdc7a3664 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx @@ -0,0 +1,36 @@ +import { Chip } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { KnowledgeIsoMark } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso' + +const KNOWLEDGE_DOCS_URL = 'https://docs.sim.ai/knowledgebase' + +interface KnowledgeEmptyStateProps { + /** Opens the create-base modal — the same action the header's primary chip runs. */ + onCreate: () => void + /** Mirrors the header chip's disabled state: no edit rights on the workspace. */ + createDisabled?: boolean +} + +/** Empty state for the knowledge bases list when the workspace has none. */ +export function KnowledgeEmptyState({ + onCreate, + createDisabled = false, +}: KnowledgeEmptyStateProps) { + return ( + } + title='Knowledge bases' + description='Upload documents to give your agents a memory they can search.' + action={ + <> + + New base + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx new file mode 100644 index 00000000000..6f03e3659ae --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx @@ -0,0 +1,228 @@ +import { cn } from '@sim/emcn' +import { + createIsoLineProps, + ISO_FILL_HIGH, + ISO_FILL_LOW, + ISO_FILL_MID, + ISO_FILL_PULSE_LOW, + ISO_STROKE as ISO_STROKE_BASE, +} from '@/components/iso/iso-illustration-style' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +const COS_30 = Math.cos(Math.PI / 6) + +type Point = readonly [number, number] + +/** Standard isometric projection: +x right-and-down, +y left-and-down, +z up. */ +function project(x: number, y: number, z: number): Point { + return [(x - y) * COS_30, (x + y) * 0.5 - z] +} + +function toPath(points: Point[]): string { + return `${points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`).join(' ')} Z` +} + +interface Box { + x: number + y: number + z: number + w: number + d: number + h: number +} + +/** The three faces an isometric viewer can see, brightest on top. */ +function boxFaces(box: Box) { + const { x, y, z, w, d, h } = box + const x1 = x + w + const y1 = y + d + const z1 = z + h + return { + top: [project(x, y, z1), project(x1, y, z1), project(x1, y1, z1), project(x, y1, z1)], + right: [project(x1, y, z1), project(x1, y1, z1), project(x1, y1, z), project(x1, y, z)], + left: [project(x, y1, z1), project(x1, y1, z1), project(x1, y1, z), project(x, y1, z)], + } +} + +/** + * Thin along y rather than x, so the cover is the face at max y — the one the + * projection turns to the left. Stacking along y then walks the set left-and-down + * toward the viewer, so drawing in offset order paints back to front. + */ +const SLABS: Box[] = [0, 90, 180].map((offset) => ({ + x: 0, + y: offset, + z: 0, + w: 208, + d: 46, + h: 236, +})) + +/** + * Lighter and thinner than the landing marks draw them. + * + * Those marks are the focal art of their section; here the mark sits beside a + * ruled grid and a skeleton feed whose lines are 1px of `--border`. Carrying + * the landing's full-weight contour made the volumes read as ink next to those, + * so the shared stroke is mixed toward `--border` and thinned to land near a + * hairline once the mark is scaled to empty-state size. Only the width diverges + * from the shared recipe; the fills are imported so a change to the iso ramp + * reaches this mark too. + */ +const ISO_STROKE = `color-mix(in srgb, ${ISO_STROKE_BASE} 55%, var(--border))` +/** Darker than any outer face — the bore's wall turns away from the light. */ +const ISO_FILL_BORE = ISO_FILL_PULSE_LOW +const KNOWLEDGE_STROKE_WIDTH = 1.9 + +/** Matches the other three resource graphics, so the set centres as one collection. */ +const MARK_HEIGHT = 148 + +/** + * Maps flat artwork into the plane of the front volume's cover. + * + * The cover is the face at max y, spanned by the volume's width going across and + * its height going up. Those two edges are its basis vectors in projected space, + * and a matrix built from them lets a plain `` be authored in face + * coordinates — the projection skews it into the right ellipse. Local units are + * world units measured on the face, so a circle stays circular *on the cover* + * instead of being stretched by the face's aspect. + */ +const COVER = SLABS[SLABS.length - 1] + +const COVER_PLANE = (() => { + const [originX, originY] = project(COVER.x, COVER.y + COVER.d, COVER.z) + return `matrix(${COS_30.toFixed(4)} 0.5 0 1 ${originX.toFixed(3)} ${(originY - COVER.h).toFixed(3)})` +})() + +const BORE_RADIUS = 62 +const BORE_CX = COVER.w / 2 +const BORE_CY = COVER.h / 2 + +/** + * The far mouth of the bore, in the same cover-plane coordinates. + * + * Boring straight back through the volume is a world-space step of `-d` along y. + * Solving the cover-plane matrix for the local offset that produces that step + * gives `(+d, -d)` — so the far mouth sits up and left of the near one by exactly + * the volume's thickness, and the sliver of near-mouth it fails to cover is the + * wall you see down the hole. + */ +const FAR_CX = BORE_CX + COVER.d +const FAR_CY = BORE_CY - COVER.d + +const BORE_MASK_ID = 'knowledge-iso-bore-mask' +const BORE_CLIP_ID = 'knowledge-iso-bore-clip' + +const ALL_POINTS: Point[] = SLABS.flatMap((box) => Object.values(boxFaces(box)).flat()) + +/** `SLABS` is a module constant and the projection is pure, so every face path is fixed. */ +const SLAB_PATHS = SLABS.map((box) => { + const faces = boxFaces(box) + return { + key: `${box.x}-${box.y}`, + left: toPath(faces.left), + right: toPath(faces.right), + top: toPath(faces.top), + } +}) + +const PADDING = 14 +const MIN_X = Math.min(...ALL_POINTS.map(([x]) => x)) - PADDING +const MAX_X = Math.max(...ALL_POINTS.map(([x]) => x)) + PADDING +const MIN_Y = Math.min(...ALL_POINTS.map(([, y]) => y)) - PADDING +const MAX_Y = Math.max(...ALL_POINTS.map(([, y]) => y)) + PADDING +const VIEW_BOX = `${MIN_X.toFixed(2)} ${MIN_Y.toFixed(2)} ${(MAX_X - MIN_X).toFixed(2)} ${(MAX_Y - MIN_Y).toFixed(2)}` + +/** + * The tables grid's corner fade, run along the other diagonal. + * + * There it dissolves toward the bottom-right, because a grid keeps its meaning + * cropped. Here the set recedes up and to the right, and the front volume carries + * the bore — so the fade is anchored at the bottom-left and eats into the back of + * the set instead, reading as more volumes behind rather than dissolving the one + * detail worth looking at. + */ +const STACK_FADE = + '[-webkit-mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [-webkit-mask-composite:source-in] [mask-composite:intersect]' + +/** Shared iso contour recipe at this mark's own weight; spread onto both paths and circles. */ +const LINE_PROPS = createIsoLineProps(ISO_STROKE, KNOWLEDGE_STROKE_WIDTH) + +/** + * Down the hole: the near mouth is floored with the wall tone, then the far mouth + * is painted over it in the cover tone of the volume standing behind — looking + * through a bore in the front volume lands on that volume's face, not on the page. + * Both are clipped to the near mouth so the bore never paints outside its opening. + */ +function BoreInterior() { + return ( + + + + + + ) +} + +/** + * Isometric knowledge-base mark on the landing page's iso-illustration recipe. + * + * Geometry is authored in a large unit space so the shared stroke constant lands + * as a hairline once the mark is scaled to empty-state size — as it does on the + * landing marks, which draw 3.2 into a ~526-unit viewBox. + */ +export function KnowledgeIsoMark() { + const width = MARK_HEIGHT * ((MAX_X - MIN_X) / (MAX_Y - MIN_Y)) + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx new file mode 100644 index 00000000000..443e1155888 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx @@ -0,0 +1,77 @@ +import { cn } from '@sim/emcn' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +const LOGS_DOCS_URL = 'https://docs.sim.ai/logs-debugging' + +/** Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface ramp. */ +const INK = { + title: 'color-mix(in srgb, var(--text-secondary) 32%, transparent)', + detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', +} as const + +interface ActivityRow { + stamp: string + title: number + detail: number +} + +const ROWS: ActivityRow[] = [ + { stamp: 'Now', title: 72, detail: 112 }, + { stamp: '12 min ago', title: 62, detail: 124 }, + { stamp: '1h ago', title: 76, detail: 100 }, + { stamp: 'Jul 8', title: 56, detail: 108 }, +] + +/** + * Vertical falloff so the feed dissolves into the page instead of ending on a hard + * last row — the list is a repeating structure, so cropping it costs nothing. + */ +const FEED_FADE = + '[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]' + +/** Four rows, sized to the ~148px the other resource graphics occupy so the frame centres the set alike. */ +function LogsGraphic() { + return ( + + ) +} + +/** Empty state for the logs list when the workspace has no runs yet. */ +export function LogsEmptyState() { + return ( + } + title='Logs' + description='Every workflow execution lands here, traced block by block.' + action={} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask.ts new file mode 100644 index 00000000000..902a6c27e67 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask.ts @@ -0,0 +1,7 @@ +/** + * A mask tile is sized to the element box and `mask-repeat` initially repeats, so + * anything drawn past that box lands in the next tile at the opaque head of the + * gradient. Every fade pins `no-repeat` rather than relying on its subject + * happening to fit inside the frame. + */ +export const MASK_NO_REPEAT = '[-webkit-mask-repeat:no-repeat] [mask-repeat:no-repeat]' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx new file mode 100644 index 00000000000..650be72ed63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx @@ -0,0 +1,49 @@ +import { Chip } from '@sim/emcn' +import { EmptyState } from '@/components/empty-state/empty-state' + +interface ResourceNoResultsProps { + /** + * The query the rows were actually filtered by — the debounced value, never the instant + * URL one, or the copy names a query the list has not run yet. Empty when only filters + * narrowed the list. + */ + search: string + /** Applied filter chips, so the copy can name filters as the thing that matched nothing. */ + filterCount: number + /** Clears the query and every filter, restoring the open folder's contents. */ + onClear: () => void +} + +/** + * What a foldered list shows when a search or filter matched nothing. + * + * Distinct from the resource's zero-data graphic, which invites you to create your first + * item and would be a lie here — see {@link resourceListState}. Without this the table + * renders an unexplained blank, which reads as a broken page rather than an empty result. + * + * Both descriptions state a scope the user cannot otherwise see, which is what earns them a + * line at all: a search spans every folder, so a bare "no results" while standing inside one + * invites exactly the wrong conclusion — that they should go looking elsewhere themselves — + * while filters narrow only the open folder. The search branch wins when both are set, + * because the wider scope is the more surprising of the two. + */ +export function ResourceNoResults({ search, filterCount, onClear }: ResourceNoResultsProps) { + const trimmed = search.trim() + return ( + + {trimmed && filterCount > 0 + ? 'Clear search and filters' + : trimmed + ? 'Clear search' + : 'Clear filters'} + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx new file mode 100644 index 00000000000..1026d13ed3d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx @@ -0,0 +1,132 @@ +import { Chip, cn } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +/** + * Neutral ink at two strengths. + * + * `--surface-4`/`--surface-5` are near-white in light mode (#f5f5f5/#f3f3f3), so + * skeleton geometry built on them dissolves against the page. Mixing + * `--text-secondary` into transparent gives a real mid-grey that inverts with the + * theme — the idiom the workflow editor's vignette uses for the one bar it needs + * you to actually see. + */ +const INK = { + header: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + cell: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', + /** + * Mixed into `--bg` rather than `transparent`: a translucent ring lets the + * grid rules underneath show through its own stroke. Opaque, and darker than + * the ink it surrounds so it reads as chrome rather than more content. + */ + selection: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))', +} as const + +/** + * Crisp at the top-left, dissolving through the bottom-right — the same + * two-gradient intersect the landing page's workflow vignette uses, so the grid + * blends into the page rather than sitting on it as a card. + */ +const CORNER_FADE = + '[-webkit-mask-image:linear-gradient(to_right,#000_62%,transparent_100%),linear-gradient(to_bottom,#000_56%,transparent_100%)] [mask-image:linear-gradient(to_right,#000_62%,transparent_100%),linear-gradient(to_bottom,#000_56%,transparent_100%)] [-webkit-mask-composite:source-in] [mask-composite:intersect]' + +const COLUMN_TEMPLATE = '88px 72px 72px 72px' + +const HEADER_WIDTHS = [36, 28, 26, 28] as const + +const CELL_WIDTHS = [ + [54, 34, 26, 38], + [44, 30, 34, 30], + [60, 38, 22, 40], + [40, 26, 30, 34], + [50, 32, 28, 36], +] as const + +/** + * The one cell held in an edit ring, placed in the top-left quadrant the corner + * fade leaves fully opaque — a selection dissolving mid-stroke would read as a + * rendering fault rather than a detail. + */ +const SELECTED_CELL = { row: 1, column: 0 } as const + +/** Skeleton grid with one cell held in an edit ring, running off two edges. */ +function TablesGraphic() { + return ( + + ) +} + +const TABLES_DOCS_URL = 'https://docs.sim.ai/tables' + +interface TablesEmptyStateProps { + /** Creates a table — the same action the header's primary chip runs. */ + onCreate: () => void + /** Mirrors the header chip's disabled state: no edit rights, or a create already in flight. */ + createDisabled?: boolean +} + +/** Empty state for the tables list when the workspace has none. */ +export function TablesEmptyState({ onCreate, createDisabled = false }: TablesEmptyStateProps) { + return ( + } + title='Tables' + description='Create a table to store structured data your agents can read and write.' + action={ + <> + + New table + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.test.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.test.ts new file mode 100644 index 00000000000..d15804df089 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' + +/** A workspace that genuinely holds nothing — the one case that earns the graphic. */ +const EMPTY = { + rowCount: 0, + isLoading: false, + isPlaceholderData: false, + error: null, + search: '', + filterCount: 0, + folderId: null, + foldersResolved: true, +} as const + +describe('isResourceListEmpty', () => { + it('is true only when the list genuinely holds nothing', () => { + expect(isResourceListEmpty(EMPTY)).toBe(true) + }) + + it('is false as soon as the list has a row', () => { + expect(isResourceListEmpty({ ...EMPTY, rowCount: 1 })).toBe(false) + }) + + it.each([ + ['the first load is still in flight', { isLoading: true }], + ['the query is serving the previous key rows', { isPlaceholderData: true }], + ['the load failed', { error: new Error('boom') }], + ['the folder tree has not resolved', { foldersResolved: false }], + ])('is false while %s', (_label, override) => { + expect(isResourceListEmpty({ ...EMPTY, ...override })).toBe(false) + }) + + it.each([ + ['a search is active', { search: 'invoices' }], + ['a filter is applied', { filterCount: 1 }], + ['a subfolder is open', { folderId: 'folder-1' }], + ])('is false when %s, because the copy would be wrong', (_label, override) => { + expect(isResourceListEmpty({ ...EMPTY, ...override })).toBe(false) + }) + + it('ignores a whitespace-only search, which filters no rows either', () => { + expect(isResourceListEmpty({ ...EMPTY, search: ' ' })).toBe(true) + }) + + it('treats a list without folder navigation as resolved at the root', () => { + const { folderId, foldersResolved, ...withoutFolders } = EMPTY + expect(isResourceListEmpty(withoutFolders)).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts new file mode 100644 index 00000000000..6ad8ddbc927 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts @@ -0,0 +1,78 @@ +interface ResourceListEmptyInput { + rowCount: number + /** The list query's first load. No rows yet means "not arrived", not "none exist". */ + isLoading: boolean + /** The query is serving the previous key's rows while the new key refetches. */ + isPlaceholderData: boolean + /** A failed load also leaves the list empty. */ + error: unknown + /** + * The search term the rows are actually filtered by — the debounced value, never the + * instant URL one. + */ + search: string + filterCount: number + /** The open folder, or `null` at the root. Omit for lists without folder navigation. */ + folderId?: string | null + /** + * Whether the folder tree has resolved. Folder rows share the list with resource rows, + * so a workspace holding only folders looks empty until they arrive. Omit for lists + * without folder navigation. + */ + foldersResolved?: boolean +} + +/** + * Whether a resource list holds nothing, as opposed to merely showing nothing. + * + * A zero-data graphic invites someone to create their first item, so it may only + * appear when that is the true answer. Every other way `rows` empties out is a + * different message and gets gated here: + * + * - A search or filter that matched nothing, or an empty subfolder — the copy would + * be wrong. The search must be the debounced value the rows are filtered by; + * reading the instant URL value flashes the graphic for one debounce window after + * a search is cleared. + * - The list still arriving. Server prefetches are allowed to seed nothing, and the + * files list deliberately seeds nothing above 300 rows — so without this the + * emptiest-looking screen is shown to the fullest workspaces. + * - The query serving the previous key's rows. Filters are part of the query key and + * every list keeps previous data, so `isLoading` is false across a filter change. + * - A failed load, which is not an invitation to create anything. + * - The folder tree still resolving, for the same reason as the rows themselves: a + * workspace whose only contents are folders reads as empty until they land. + */ +export function isResourceListEmpty(input: ResourceListEmptyInput): boolean { + return resourceListState(input) === 'empty' +} + +/** + * What a resource list should render in place of rows. + * + * - `rows` — rows are showing, or none have settled yet, so the table renders itself. + * - `empty` — the workspace genuinely holds nothing; the zero-data graphic is the true answer. + * - `no-results` — a search or filter matched nothing. Distinct from `empty`, because the + * copy that invites you to create your first item would be a lie, and distinct from `rows`, + * because rendering neither leaves an unexplained blank table. + * + * One function rather than two predicates: the two states share every "the rows have actually + * arrived" condition, and when those lived in both places a new condition could be added to + * one and silently left off the other. + */ +export function resourceListState({ + rowCount, + isLoading, + isPlaceholderData, + error, + search, + filterCount, + folderId = null, + foldersResolved = true, +}: ResourceListEmptyInput): 'rows' | 'empty' | 'no-results' { + const settled = rowCount === 0 && !isLoading && !isPlaceholderData && !error && foldersResolved + if (!settled) return 'rows' + const narrowed = Boolean(search.trim()) || filterCount > 0 + if (narrowed) return 'no-results' + /** An empty subfolder is not an empty workspace, so it gets neither state. */ + return folderId === null ? 'empty' : 'rows' +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx new file mode 100644 index 00000000000..fa6007c8e02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx @@ -0,0 +1,27 @@ +import type { ComponentType } from 'react' + +interface ResourceNotFoundProps { + icon: ComponentType<{ className?: string }> + title: string + description: string +} + +/** + * Full-page screen for a resource that could not be loaded and has no shell left to + * draw — a knowledge base or a document that was deleted or moved. + * + * Distinct from the `emptyState` slot on {@link Resource.Table}: that one keeps the + * chrome and reports a failure *within* a page that still exists. This replaces the + * page, so it is only right when the thing the page is about is the thing that is gone. + */ +export function ResourceNotFound({ icon: Icon, title, description }: ResourceNotFoundProps) { + return ( +
+ +
+

{title}

+

{description}

+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 31ca47ae1bc..5e26ddce699 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -225,6 +225,17 @@ interface ResourceTableProps { * chrome and positioning; it never alters the table's rendering. */ overlay?: ReactNode + /** + * Sanctioned empty slot. Rendered below the column headers when `rows` is + * empty, filling the otherwise blank scroll area. It never replaces the table + * region or the headers — the chrome guarantee holds — so a consumer can show + * a zero-data graphic without the list losing its structure. + * + * The table gives the slot a flex column that fills the remaining scroll area, so + * the node decides how to sit in it rather than depending on the scroll container's + * own layout. + */ + emptyState?: ReactNode } /** @@ -263,10 +274,13 @@ const ResourceTable = memo(function ResourceTable({ isLoadingMore, pagination, overlay, + emptyState, }: ResourceTableProps) { const scrollRef = useRef(null) const loadMoreRef = useRef(null) + const showEmptyState = rows.length === 0 && Boolean(emptyState) + const [contextMenuRowId, setContextMenuRowId] = useState(null) const wrappedOnRowContextMenu = useCallback( @@ -358,7 +372,10 @@ const ResourceTable = memo(function ResourceTable({
+ {showEmptyState ?
{emptyState}
: null} {hasMore && (
{isLoadingMore && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f2fce25c67a..79f96559e3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -62,6 +62,7 @@ import { OwnerAvatar, ownerCell, Resource, + resourceListState, selectionLabel, timeCell, useResourceRowSelection, @@ -74,17 +75,26 @@ import { breadcrumbFolderChain, buildDescendantIndex, buildMoveOptionsExcludingSubtrees, + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, folderedResourceListHref, + folderLocationLabel, folderRowId, + isSearchingResources, parseFolderedRowId, parseMoveOptionValue, + scopeFolderedItems, sortResources, splitFolderedRowIds, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { + FilesEmptyState, + ResourceNoResults, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' 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' @@ -134,10 +144,10 @@ import { useUploadWorkspaceFile, useWorkspaceFiles, } from '@/hooks/queries/workspace-files' -import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' @@ -191,6 +201,13 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +/** + * Deliberately absent from {@link filesSortParams}, so the location column is not offered in + * the sort menu — those columns are a URL contract, and ordering by path is not worth + * persisting in a shared link before anyone asks for it. + */ +const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN] + const MIME_TYPE_LABELS: Record = { 'application/pdf': 'PDF', 'application/zip': 'ZIP', @@ -263,8 +280,19 @@ export function Files() { } }, [permissionConfig.hideFilesTab, router, workspaceId]) - const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) - const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) + const { + data: files = EMPTY_WORKSPACE_FILES, + isLoading, + isPlaceholderData, + error, + } = useWorkspaceFiles(workspaceId) + const { + data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, + isSuccess: foldersLoaded, + isPlaceholderData: foldersArePlaceholder, + } = useWorkspaceFileFolders(workspaceId) + /** Matches `FolderNavigation.foldersResolved`, which the other resource pages read. */ + const foldersResolved = foldersLoaded && !foldersArePlaceholder const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedFileIds = usePinnedIds(workspaceId, 'file') // Folders pin under their own resource type, so their pinned set is a separate query. @@ -355,7 +383,30 @@ export function Files() { (value, options) => setFileFilters({ search: value }, options), { debounceMs: FILES_SEARCH_DEBOUNCE_MS } ) - const debouncedSearchTerm = useDebounce(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS) + const debouncedSearchTerm = useSearchFilterValue(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS) + + /** + * Files' equivalent of `useFolderNavigation`'s `openFolder`, which Tables and Knowledge + * use — kept local because this page owns its own param group and has to clear `new` in + * the same batch. Change the two together. + * + * Opens a folder, clearing any active query on the way in. Search spans every folder, so a + * folder in the results is a destination the user picked out of them — not a narrower place + * to keep searching. Carrying the term across would filter the folder they just opened down + * to the same matches they were already looking at, which is how this read as "the folder is + * empty". + * + * The writes land in one URL update: nuqs batches same-tick writes across param groups and + * escalates the batch to `push`, so this stays a single history entry and Back returns to + * the results that led here. + */ + const navigateToFolder = useCallback( + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + setSearchTerm('') + void setFilesParams({ folderId, new: null }, options) + }, + [setSearchTerm, setFilesParams] + ) const { sort: sortColumn, @@ -515,21 +566,31 @@ export function Files() { return totalSize }, [files, folders]) - const visibleFolders = useMemo(() => { - const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const needle = debouncedSearchTerm.trim().toLowerCase() - return needle - ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) - : siblings - }, [folders, currentFolderId, debouncedSearchTerm]) + /** + * A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. A + * matching folder anywhere in the workspace is a result in its own right, since opening it + * is often what the user was looking for. + */ + const isSearching = isSearchingResources(debouncedSearchTerm) + + const visibleFolders = useMemo( + () => + scopeFolderedItems(folders, { + currentFolderId, + search: debouncedSearchTerm, + getParentId: (folder) => folder.parentId ?? null, + getSearchText: (folder) => [folder.name], + }), + [folders, currentFolderId, debouncedSearchTerm] + ) const filteredFiles = useMemo(() => { - const needle = debouncedSearchTerm.trim().toLowerCase() - let result = needle - ? files.filter( - (f) => (f.folderId ?? null) === currentFolderId && f.name.toLowerCase().includes(needle) - ) - : files.filter((f) => (f.folderId ?? null) === currentFolderId) + let result = scopeFolderedItems(files, { + currentFolderId, + search: debouncedSearchTerm, + getParentId: (f) => f.folderId ?? null, + getSearchText: (f) => [f.name], + }) if (typeFilter.length > 0) { result = result.filter((f) => { @@ -653,6 +714,16 @@ export function Files() { created: timeCell(folder.createdAt), owner: ownerCell(folder.userId, membersById), updated: timeCell(folder.updatedAt), + /** + * A folder's location is its parent's path, not its own. Built only while + * searching: the column is absent otherwise, so resolving an ancestor chain + * per row would be work every row throws away. + */ + location: isSearching + ? { + label: folderLocationLabel(folder.parentId, folderById, FILES_HEADER.rootLabel), + } + : EMPTY_LOCATION_CELL, }, } } @@ -677,10 +748,13 @@ export function Files() { created: timeCell(file.uploadedAt), owner: ownerCell(file.uploadedBy, membersById), updated: timeCell(file.updatedAt), + location: isSearching + ? { label: folderLocationLabel(file.folderId, folderById, FILES_HEADER.rootLabel) } + : EMPTY_LOCATION_CELL, }, } }), - [sortedEntries, membersById, folderSizeMap] + [sortedEntries, membersById, folderSizeMap, folderById, isSearching] ) const rows: ResourceRow[] = useMemo(() => { @@ -730,6 +804,14 @@ export function Files() { async (filesToUpload: File[], targetFolderId = currentFolderId) => { if (!workspaceId || filesToUpload.length === 0 || !canEdit) return + /** + * Uploads land in a folder, but a live query is showing results from across the + * workspace and an uploaded name rarely matches it — the new rows would not render and + * the upload would read as having failed. Cleared up front so the list is already + * showing the destination as the progress counter runs. + */ + setSearchTerm('') + const oversized: string[] = [] const sizeFiltered = filesToUpload.filter((f) => { if (f.size > MAX_WORKSPACE_FILE_SIZE) { @@ -794,7 +876,7 @@ export function Files() { setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) } }, - [workspaceId, canEdit, currentFolderId, notifyLimit] + [workspaceId, canEdit, currentFolderId, notifyLimit, setSearchTerm] ) const rowDragDropConfig = useFolderRowDragDrop({ @@ -817,10 +899,20 @@ export function Files() { .catch((error) => logger.error('Failed to move items:', error)) }, selection: { selectedRowIds, visibleRowIds, replaceSelection }, + /** + * Moves the folder without touching the query, unlike every other navigation here. + * + * A spring-open is a step inside a drag, not a destination the user chose, and it is + * undone when the drag ends without a drop. Clearing the query on the way in would be + * clearing it on the way back out too — the restore runs through this same callback — + * so an abandoned drag would silently discard the search that produced the row being + * dragged, with `history: 'replace'` leaving nothing for Back to recover. + */ onSpringOpenFolder: (folderId, options) => { void setFilesParams({ folderId, new: null }, options) }, currentFolderId, + bodyDropFolderId: isSearching ? undefined : currentFolderId, /** * The one thing this list does that the others do not. Folder rows still highlight and * spring open for an OS file drag — filing an upload into a nested folder is the same @@ -1158,12 +1250,19 @@ export function Files() { name, parentId: currentFolderId, }) + /** + * The new folder goes into the open folder, but a live query is showing results from + * across the workspace and "New folder" almost never matches it — the row would not + * render and the rename it opens would have nothing to attach to, so creating would + * read as having done nothing at all. + */ + setSearchTerm('') 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]) + }, [workspaceId, folders, currentFolderId, listRename.startRename, setSearchTerm]) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { @@ -1189,7 +1288,7 @@ export function Files() { const item = contextMenuItemRef.current if (!item) return if (item.kind === 'folder') { - void setFilesParams({ folderId: item.folder.id, new: null }) + navigateToFolder(item.folder.id) closeContextMenu() return } @@ -1199,7 +1298,7 @@ export function Files() { : `/workspace/${workspaceId}/files/${item.file.id}` ) closeContextMenu() - }, [closeContextMenu, router, workspaceId, setFilesParams]) + }, [closeContextMenu, router, workspaceId, navigateToFolder]) const handleContextMenuDownload = useCallback(() => { const item = contextMenuItemRef.current @@ -1435,7 +1534,7 @@ export function Files() { if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { - void setFilesParams({ folderId: parsed.id, new: null }) + navigateToFolder(parsed.id) return } const file = fileByIdRef.current.get(parsed.id) @@ -1443,14 +1542,20 @@ export function Files() { setExtractTargetId(file.id) return } + /** + * The file's own folder, not the open one. A search result usually lives elsewhere, + * and this param is what the viewer returns to on close or delete — carrying the open + * folder would send the user to a folder the file was never in. + */ + const fileFolderId = file?.folderId ?? null router.push( - currentFolderId - ? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${currentFolderId}` + fileFolderId + ? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${fileFolderId}` : `/workspace/${workspaceId}/files/${parsed.id}` ) } }, - [router, workspaceId, currentFolderId, setFilesParams] + [router, workspaceId, navigateToFolder] ) const handleExtract = async () => { @@ -1533,13 +1638,6 @@ export function Files() { ] ) - const handleNavigateToListFolder = useCallback( - (folderId: string | null) => { - void setFilesParams({ folderId, new: null }) - }, - [setFilesParams] - ) - const listFolderChain = useMemo( () => breadcrumbFolderChain(currentFolderId, folderById), [currentFolderId, folderById] @@ -1574,7 +1672,7 @@ export function Files() { rootLabel: FILES_HEADER.rootLabel, rootIcon: FILES_HEADER.rootIcon, breadcrumbs: listFolderChain, - onNavigate: handleNavigateToListFolder, + onNavigate: navigateToFolder, currentFolderEditing: openListFolder && breadcrumbRename.editingId === openListFolder.id ? { @@ -1601,7 +1699,7 @@ export function Files() { [ listFolderChain, openListFolder, - handleNavigateToListFolder, + navigateToFolder, canEdit, userPermissions.isLoading, breadcrumbRename.editingId, @@ -1804,6 +1902,22 @@ export function Files() { return tags }, [typeFilter, sizeFilter, uploadedByFilter, membersById]) + const listState = resourceListState({ + rowCount: rows.length, + isLoading, + isPlaceholderData, + error, + search: debouncedSearchTerm, + filterCount: filterTags.length, + folderId: currentFolderId, + foldersResolved, + }) + + const clearSearchAndFilters = () => { + setSearchTerm('') + void setFileFilters({ type: null, size: null, uploadedBy: null }) + } + if (fileIdFromRoute && !selectedFile && isLoading) { return ( @@ -1904,8 +2018,22 @@ export function Files() { filter={filterConfig} /> + ) : listState === 'no-results' ? ( + + ) : undefined + } selectable={selectableConfig} rowDragDrop={rowDragDropConfig} onRowClick={handleRowClick} 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 5c9e0e4d99d..3f2d482dbe0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -7,15 +7,19 @@ import { ChevronUp, Database, FileText, + FileX, Pencil, Plus, TagIcon, Trash, + TriangleAlert, } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' import type { ChunkData } from '@/lib/knowledge/types' import { formatTokenCount } from '@/lib/tokenization' import type { @@ -29,7 +33,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + Resource, + ResourceNotFound, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -138,6 +146,41 @@ const CHUNK_COLUMNS: ResourceColumn[] = [ { id: 'status', header: 'Status', widthMultiplier: 0.75 }, ] +/** Stable identity for the error branch's empty row set. */ +const EMPTY_CHUNK_ROWS: ResourceRow[] = [] + +/** Longer than this and a server message pushes the frame taller than the table body. */ +const ERROR_MESSAGE_MAX_LENGTH = 160 + +interface ChunkLoadErrorProps { + message: string + /** + * Which read failed. A failed search leaves the loaded chunks intact, so saying the + * chunks could not be loaded would be untrue — it is the search that did not run. + */ + kind: 'load' | 'search' +} + +/** + * A failed read, drawn where the rows would have been. + * + * The table keeps its chrome and its controls — the headers still render, and the search + * box stays so a search that triggered the failure can be cleared. Only the body says + * what went wrong, instead of the page going silently blank. + * + * Tinted with the error token: at the muted weight the other empty states use, a failure + * is indistinguishable from "nothing here yet", which is the confusion this exists to end. + */ +function ChunkLoadError({ message, kind }: ChunkLoadErrorProps) { + return ( + } + title={kind === 'search' ? 'Search failed' : "Couldn't load chunks"} + description={truncate(message, ERROR_MESSAGE_MAX_LENGTH)} + /> + ) +} + export function Document({ knowledgeBaseId, documentId, @@ -242,7 +285,7 @@ export function Document({ } ) - const searchError = searchQueryError instanceof Error ? searchQueryError.message : null + const searchError = searchQueryError ? getErrorMessage(searchQueryError) : null const [selectedChunks, setSelectedChunks] = useState>(() => new Set()) @@ -334,8 +377,6 @@ export function Document({ closeMenu: closeContextMenu, } = useContextMenu() - const combinedError = documentError || searchError || initialError - const isConnectorDocument = Boolean(documentData?.connectorId) const effectiveDocumentName = documentData?.filename || documentName || 'Document' /** @@ -351,6 +392,17 @@ export function Document({ const DocumentIcon = ConnectorIcon || getDocumentIcon(documentData?.mimeType ?? '', effectiveDocumentName) const isCompleted = documentData?.processingStatus === 'completed' + + /** + * Kept separate from `documentError`: without the document there is no page to draw, + * while a failed chunk read still has one to frame it. + * + * A document that is not `completed` is excluded, because the chunk read rejects for + * those by design — `requireChunkReadable` throws `KnowledgeDocumentNotReadyError` + * before it queries anything. That is the document's state, not a failure, and + * `chunkRows` already renders a row saying which state it is in. + */ + const chunkError = isCompleted ? initialError || searchError : null const canEdit = userPermissions.canEdit === true const isInEditorView = selectedChunkId !== null || isCreatingNewChunk @@ -533,7 +585,7 @@ export function Document({ /** * `Knowledge Base / …the base's folders / / `. Every view on this route is that - * trail with a different last crumb — the document, a chunk, an error, a loading placeholder + * trail with a different last crumb — the document, a chunk, a loading placeholder * — so it is built once here rather than restated per view. */ const documentTrail = useCallback( @@ -572,35 +624,30 @@ export function Document({ const breadcrumbs = useMemo( () => - documentTrail( - combinedError - ? { label: 'Error', terminal: true } - : { - label: documentCrumbLabel, - icon: DocumentIcon, - editing: docRename.editingId - ? { - isEditing: true, - value: docRename.editValue, - onChange: docRename.setEditValue, - onSubmit: docRename.submitRename, - onCancel: docRename.cancelRename, - disabled: docRename.isSaving, - } - : undefined, - dropdownItems: [ - ...(userPermissions.canEdit - ? [ - { label: 'Rename', icon: Pencil, onClick: handleStartDocRename }, - { label: 'Tags', icon: TagIcon, onClick: handleShowTags }, - { label: 'Delete', icon: Trash, onClick: handleShowDeleteDoc }, - ] - : []), - ], + documentTrail({ + label: documentCrumbLabel, + icon: DocumentIcon, + editing: docRename.editingId + ? { + isEditing: true, + value: docRename.editValue, + onChange: docRename.setEditValue, + onSubmit: docRename.submitRename, + onCancel: docRename.cancelRename, + disabled: docRename.isSaving, } - ), + : undefined, + dropdownItems: [ + ...(userPermissions.canEdit + ? [ + { label: 'Rename', icon: Pencil, onClick: handleStartDocRename }, + { label: 'Tags', icon: TagIcon, onClick: handleShowTags }, + { label: 'Delete', icon: Trash, onClick: handleShowDeleteDoc }, + ] + : []), + ], + }), [ - combinedError, documentTrail, documentCrumbLabel, DocumentIcon, @@ -914,6 +961,12 @@ export function Document({ } : undefined + /** + * A failed read paged nothing, so the bar would be counting pages that were never + * fetched. Read by the table and by the action bar's offset, which has to agree. + */ + const tablePagination = chunkError ? undefined : paginationConfig + const sortConfig: SortConfig = useMemo( () => ({ options: [ @@ -1099,6 +1152,21 @@ export function Document({ saveStatus, ]) + /** + * Ahead of the editor branches on purpose — `selectedChunkId` renders the chunk editor + * without checking for a document, so a document that failed to load has to + * short-circuit before it. Mirrors the base page's 'not found' screen one level up. + */ + if (documentError && !documentData) { + return ( + + ) + } + if (isCreatingNewChunk && documentData) { return ( <> @@ -1191,18 +1259,23 @@ export function Document({ ]} /> + ) : undefined + } + selectable={chunkError ? undefined : selectableConfig} onRowClick={isCompleted ? handleChunkClick : undefined} onRowContextMenu={isCompleted ? handleChunkContextMenu : undefined} - pagination={paginationConfig} + pagination={tablePagination} /> @@ -1223,7 +1296,7 @@ export function Document({ /> 0 && !isConnectorDocument ? handleBulkEnable : undefined} onDisable={enabledCount > 0 && !isConnectorDocument ? handleBulkDisable : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index a01bb376e57..5164b2dde68 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -61,7 +61,9 @@ import type { import { FILTER_SECTION_LABEL_CLASS, FloatingOverflowText, + isResourceListEmpty, Resource, + ResourceNotFound, } from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, @@ -69,6 +71,7 @@ import { folderedResourceListHref, useFolderAncestors, } from '@/app/workspace/[workspaceId]/components/folders' +import { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' import { ActionBar, @@ -411,6 +414,7 @@ export function KnowledgeBase({ const { documents, pagination, + isLoading: isLoadingDocuments, isPlaceholderData: isPlaceholderDocuments, error: documentsError, hasProcessingDocuments, @@ -1145,6 +1149,25 @@ export function KnowledgeBase({ [enabledFilter, tagFilterEntries] ) + /** + * The zero-data graphic is for a base that holds no documents — not a search or a + * filter that matched nothing, and not a page past the last one. Counts on the + * server's `total` rather than the visible rows, because this list is paginated: + * an empty page 2 is a paging position, not an empty base. + * + * The remaining gates mirror the workspace resource pages — the list must have + * arrived, must not be serving the previous query key's rows, and must not have + * failed. This list has no folders, so the folder arguments are omitted. + */ + const showEmptyState = isResourceListEmpty({ + rowCount: pagination.total, + isLoading: isLoadingDocuments, + isPlaceholderData: isPlaceholderDocuments, + error: documentsError, + search: debouncedSearchQuery, + filterCount: filterTags.length, + }) + const selectableConfig: SelectableConfig = { selectedIds: selectedDocuments, onSelectRow: handleSelectDocument, @@ -1236,15 +1259,11 @@ export function KnowledgeBase({ if (error && !knowledgeBase) { return ( -
- -
-

Knowledge base not found

-

- This knowledge base may have been deleted or moved -

-
-
+ ) } @@ -1271,6 +1290,14 @@ export function KnowledgeBase({ + ) : undefined + } selectable={selectableConfig} onRowClick={handleDocumentClick} onRowContextMenu={handleDocumentContextMenu} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 207f8b9599e..d6227a72955 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -28,6 +28,7 @@ import { ownerCell, Resource, reportBulkOutcome, + resourceListState, selectionLabel, timeCell, useResourceRowSelection, @@ -40,20 +41,29 @@ import { buildDescendantIndex, buildMoveOptions, buildMoveOptionsExcludingSubtrees, + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, + folderLocationLabel, folderRow, folderRowId, + isSearchingResources, nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, + scopeFolderedItems, sortResources, splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { + KnowledgeEmptyState, + ResourceNoResults, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -67,7 +77,6 @@ import { knowledgeSortParams, knowledgeUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/search-params' -import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -82,10 +91,10 @@ import { } from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' -import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' import type { WorkflowFolder } from '@/stores/folders/types' @@ -110,6 +119,8 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN] + const KNOWLEDGE_BASE_ICON = const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [ @@ -191,7 +202,7 @@ export function Knowledge() { } }, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId]) - const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId) + const { knowledgeBases, isLoading, isPlaceholderData, error } = useKnowledgeBasesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) /** * Indexed once: `ownerCell` resolves a member per row, so passing the raw array makes the @@ -228,6 +239,7 @@ export function Knowledge() { const { currentFolderId, setCurrentFolderId, + openFolder, ancestors: breadcrumbs, folders, folderById, @@ -235,6 +247,8 @@ export function Knowledge() { } = useFolderNavigation({ resourceType: FOLDER_RESOURCE_TYPE, workspaceId, + /** Declared below; only ever called from a click, long after this render initializes it. */ + onBeforeOpenFolder: () => setSearchQuery(''), }) const createFolder = useCreateFolder() @@ -259,7 +273,7 @@ export function Knowledge() { const setSearchQuery = useDebouncedSearchSetter((value, options) => setKnowledgeFilters({ search: value }, options) ) - const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS) + const debouncedSearchQuery = useSearchFilterValue(urlSearchQuery, SEARCH_DEBOUNCE_MS) const { sort: sortColumn, @@ -452,29 +466,39 @@ export function Knowledge() { * Files page. The resource filters (connectors/content/owner) describe properties a folder * does not have, so folders answer only to the search term. */ - const visibleFolders = useMemo(() => { - const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const needle = debouncedSearchQuery.trim().toLowerCase() - return needle - ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) - : siblings - }, [folders, currentFolderId, debouncedSearchQuery]) + /** A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. */ + const isSearching = isSearchingResources(debouncedSearchQuery) + + const visibleFolders = useMemo( + () => + scopeFolderedItems(folders, { + currentFolderId, + search: debouncedSearchQuery, + getParentId: (folder) => folder.parentId ?? null, + getSearchText: (folder) => [folder.name], + }), + [folders, currentFolderId, debouncedSearchQuery] + ) const processedKBs = useMemo(() => { - /** - * A `folderId` that no longer names an active folder — a base restored on its own out of - * Recently Deleted while its folder stayed archived, or a cascade that failed partway — - * would otherwise match no level at all and leave the base unreachable from every view. - * Fall it back to the root instead — but only once `foldersResolved` says the index is the - * complete set for THIS workspace. Gating on a loading flag instead would treat an errored - * fetch, a disabled query, or the previous workspace's cached folders as "no such folder" - * and drag every foldered base to the root. - */ - let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter((kb) => { - const folderId = kb.folderId ?? null - const effectiveFolderId = - !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null - return effectiveFolderId === currentFolderId + let result = scopeFolderedItems(knowledgeBases, { + currentFolderId, + search: debouncedSearchQuery, + /** + * A `folderId` that no longer names an active folder — a base restored on its own out of + * Recently Deleted while its folder stayed archived, or a cascade that failed partway — + * would otherwise match no level at all and leave the base unreachable from every view. + * Fall it back to the root instead — but only once `foldersResolved` says the index is the + * complete set for THIS workspace. Gating on a loading flag instead would treat an errored + * fetch, a disabled query, or the previous workspace's cached folders as "no such folder" + * and drag every foldered base to the root. + */ + getParentId: (kb) => { + const folderId = kb.folderId ?? null + return !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null + }, + /** A base is findable by its description as well as its name. */ + getSearchText: (kb) => [kb.name, kb.description], }) if (connectorFilter.length > 0) { @@ -587,6 +611,16 @@ export function Knowledge() { created: timeCell(item.folder.createdAt), owner: ownerCell(item.folder.userId, membersById), updated: timeCell(item.folder.updatedAt), + /** A folder's location is its parent's path, not its own. */ + location: isSearching + ? { + label: folderLocationLabel( + item.folder.parentId, + folderById, + ROOT_BREADCRUMB_LABEL + ), + } + : EMPTY_LOCATION_CELL, }, }) } @@ -610,10 +644,13 @@ export function Knowledge() { created: timeCell(base.createdAt), owner: ownerCell(base.userId, membersById), updated: timeCell(base.updatedAt), + location: isSearching + ? { label: folderLocationLabel(base.folderId, folderById, ROOT_BREADCRUMB_LABEL) } + : EMPTY_LOCATION_CELL, }, } }), - [sortedEntries, membersById] + [sortedEntries, membersById, folderById, isSearching] ) /** @@ -708,7 +745,7 @@ export function Knowledge() { const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { - setCurrentFolderId(parsed.id) + openFolder(parsed.id) return } @@ -717,7 +754,7 @@ export function Knowledge() { const urlParams = new URLSearchParams({ kbName: kb.name }) router.push(`/workspace/${workspaceId}/knowledge/${parsed.id}?${urlParams.toString()}`) }, - [router, workspaceId, setCurrentFolderId] + [router, workspaceId, openFolder] ) const handleRowContextMenu = useCallback( @@ -828,8 +865,8 @@ export function Knowledge() { const handleOpenFolder = useCallback(() => { const folder = activeFolderRef.current - if (folder) setCurrentFolderId(folder.id) - }, [setCurrentFolderId]) + if (folder) openFolder(folder.id) + }, [openFolder]) const handleCopyFolderId = useCallback(() => { const folder = activeFolderRef.current @@ -856,16 +893,17 @@ export function Knowledge() { setActiveFolder(null) // Deleting the folder you are standing in leaves the list pointed at an archived // folder, which renders as an empty page with a dead breadcrumb — step out to its - // parent instead. + // parent instead. Not `openFolder`: this is a forced correction, so it must neither + // clear an active search nor push a back-stack entry aimed at the deleted folder. if (currentFolderIdRef.current === folder.id) { - setCurrentFolderId(folder.parentId) + setCurrentFolderId(folder.parentId, { history: 'replace' }) } } catch (deleteError) { logger.error('Failed to delete folder', deleteError) toast.error(getErrorMessage(deleteError, 'Failed to delete folder')) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, setCurrentFolderId]) + }, [workspaceId, openFolder]) const descendantsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) @@ -1098,6 +1136,7 @@ export function Knowledge() { selection: { selectedRowIds, visibleRowIds, replaceSelection }, onSpringOpenFolder: setCurrentFolderId, currentFolderId, + bodyDropFolderId: isSearching ? undefined : currentFolderId, }) const headerActions: ResourceAction[] = useMemo( @@ -1125,7 +1164,7 @@ export function Knowledge() { rootLabel: ROOT_BREADCRUMB_LABEL, rootIcon: FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon, breadcrumbs, - onNavigate: setCurrentFolderId, + onNavigate: openFolder, currentFolderEditing: breadcrumbRename.editingId && breadcrumbRename.editingId === currentFolderId ? { @@ -1159,7 +1198,7 @@ export function Knowledge() { [ breadcrumbs, currentFolderId, - setCurrentFolderId, + openFolder, canEdit, breadcrumbRename.editingId, breadcrumbRename.editValue, @@ -1338,6 +1377,22 @@ export function Knowledge() { return tags }, [connectorFilter, contentFilter, ownerFilter, members]) + const listState = resourceListState({ + rowCount: rows.length, + isLoading, + isPlaceholderData, + error, + search: debouncedSearchQuery, + filterCount: filterTags.length, + folderId: currentFolderId, + foldersResolved, + }) + + const clearSearchAndFilters = () => { + setSearchQuery('') + void setKnowledgeFilters({ connector: null, content: null, owner: null }) + } + return ( <> @@ -1355,8 +1410,19 @@ export function Knowledge() { filter={filterConfig} /> + ) : listState === 'no-results' ? ( + + ) : undefined + } selectable={canEdit ? selectableConfig : undefined} rowDragDrop={rowDragDropConfig} onRowClick={handleRowClick} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts deleted file mode 100644 index 1656b04f25d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { KnowledgeBaseData } from '@/lib/knowledge/types' - -/** - * Filter knowledge bases by search query - */ -export function filterKnowledgeBases( - knowledgeBases: KnowledgeBaseData[], - searchQuery: string -): KnowledgeBaseData[] { - if (!searchQuery.trim()) { - return knowledgeBases - } - - const query = searchQuery.trim().toLowerCase() - return knowledgeBases.filter( - (kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query) - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 17cc80be70c..d49b7a691fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -57,7 +57,12 @@ import type { SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId]/components' +import { + isResourceListEmpty, + Resource, + type ResourceTableHandle, +} from '@/app/workspace/[workspaceId]/components' +import { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { @@ -900,6 +905,16 @@ export default function Logs() { setTimeRange, ]) + /** Logs has no folder navigation, so the graphic means "nothing has ever run here". */ + const showEmptyState = isResourceListEmpty({ + rowCount: rows.length, + isLoading: logsQuery.isLoading, + isPlaceholderData: logsQuery.isPlaceholderData, + error: logsQuery.error, + search: debouncedSearchQuery, + filterCount: filterTags.length, + }) + const workflowsData = useMemo( () => Object.values(allWorkflows).map((w) => ({ @@ -1194,6 +1209,7 @@ export default function Logs() { virtualized columns={LOG_COLUMNS} rows={rows} + emptyState={showEmptyState ? : undefined} selectedRowId={selectedLogId} onRowClick={handleLogClick} onRowHover={handleLogHover} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 57e986f0aa7..4438d755b4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -563,15 +563,9 @@ export function General() { {!isAuthDisabled && ( -
-
- - setShowDeleteAccountModal(true)}>Delete -
-

- Permanently deletes your account and everything only you can reach — workflows, - chats, files, knowledge bases and credentials. This cannot be undone. -

+
+ + setShowDeleteAccountModal(true)}>Delete
)} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index dc5e89a8742..b5ffc797b04 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -27,6 +27,7 @@ import { ownerCell, Resource, reportBulkOutcome, + resourceListState, selectionLabel, timeCell, useResourceRowSelection, @@ -39,20 +40,29 @@ import { buildDescendantIndex, buildMoveOptions, buildMoveOptionsExcludingSubtrees, + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, + folderLocationLabel, folderRow, folderRowId, + isSearchingResources, nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, + scopeFolderedItems, sortResources, splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { + ResourceNoResults, + TablesEmptyState, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -83,10 +93,10 @@ import { } from '@/hooks/queries/tables' import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' -import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' import type { WorkflowFolder } from '@/stores/folders/types' import { useImportTrayStore } from '@/stores/table/import-tray/store' @@ -102,6 +112,8 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN] + /** This list's private drag MIME, so a drag started on another list is never mistaken for one * of these rows. */ const TABLE_ROW_DRAG_MIME = 'application/x-sim-workspace-table-rows' @@ -135,7 +147,12 @@ export function Tables() { // mutation service) invalidates the list so this view refetches without waiting for staleness. useWorkspaceTablesRoom(workspaceId) - const { data: tables = EMPTY_TABLES, error } = useTablesList(workspaceId) + const { + data: tables = EMPTY_TABLES, + isLoading, + isPlaceholderData, + error, + } = useTablesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedTableIds = usePinnedIds(workspaceId, 'table') // Folder pins live in their own `resourceType` namespace, so a page listing @@ -147,6 +164,7 @@ export function Tables() { const { currentFolderId, setCurrentFolderId, + openFolder, ancestors: folderChain, folders, folderById, @@ -154,6 +172,8 @@ export function Tables() { } = useFolderNavigation({ resourceType: 'table', workspaceId, + /** Declared below; only ever called from a click, long after this render initializes it. */ + onBeforeOpenFolder: () => setSearchTerm(''), }) /** @@ -243,7 +263,7 @@ export function Tables() { const setSearchTerm = useDebouncedSearchSetter((value, options) => setTableFilters({ search: value }, options) ) - const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS) + const debouncedSearchTerm = useSearchFilterValue(urlSearchTerm, SEARCH_DEBOUNCE_MS) const setRowCountFilter = useCallback( (next: string[]) => setTableFilters({ rows: next }), @@ -299,32 +319,39 @@ export function Tables() { */ const descendantFolderIds = useMemo(() => buildDescendantIndex(folders), [folders]) - const visibleFolders = useMemo(() => { - const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const needle = debouncedSearchTerm.trim().toLowerCase() - return needle - ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) - : siblings - }, [folders, currentFolderId, debouncedSearchTerm]) + /** A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. */ + const isSearching = isSearchingResources(debouncedSearchTerm) + + const visibleFolders = useMemo( + () => + scopeFolderedItems(folders, { + currentFolderId, + search: debouncedSearchTerm, + getParentId: (folder) => folder.parentId ?? null, + getSearchText: (folder) => [folder.name], + }), + [folders, currentFolderId, debouncedSearchTerm] + ) const processedTables = useMemo(() => { - const query = debouncedSearchTerm.trim().toLowerCase() - /** - * A `folderId` that no longer names an active folder — restored on its own out - * of Recently Deleted while its folder stayed archived — would otherwise match - * no level at all and leave the table unreachable from every view. Fall it back - * to the root instead — but only once `foldersResolved` says the index is the complete - * set for THIS workspace. Gating on a loading flag instead would treat an errored fetch, - * a disabled query, or the previous workspace's cached folders as "no such folder" and - * drag every foldered table to the root. - */ - let result = tables.filter((t) => { - const folderId = t.folderId ?? null - const effectiveFolderId = - !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null - return effectiveFolderId === currentFolderId + let result = scopeFolderedItems(tables, { + currentFolderId, + search: debouncedSearchTerm, + /** + * A `folderId` that no longer names an active folder — restored on its own out + * of Recently Deleted while its folder stayed archived — would otherwise match + * no level at all and leave the table unreachable from every view. Fall it back + * to the root instead — but only once `foldersResolved` says the index is the complete + * set for THIS workspace. Gating on a loading flag instead would treat an errored fetch, + * a disabled query, or the previous workspace's cached folders as "no such folder" and + * drag every foldered table to the root. + */ + getParentId: (t) => { + const folderId = t.folderId ?? null + return !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null + }, + getSearchText: (t) => [t.name], }) - if (query) result = result.filter((t) => t.name.toLowerCase().includes(query)) if (rowCountFilter.length > 0) { result = result.filter((t) => { @@ -421,6 +448,10 @@ export function Tables() { created: timeCell(item.folder.createdAt), owner: ownerCell(item.folder.userId, membersById), updated: timeCell(item.folder.updatedAt), + /** A folder's location is its parent's path, not its own. */ + location: isSearching + ? { label: folderLocationLabel(item.folder.parentId, folderById, ROOT_LABEL) } + : EMPTY_LOCATION_CELL, }, }) } @@ -445,10 +476,13 @@ export function Tables() { created: timeCell(table.createdAt), owner: ownerCell(table.createdBy, membersById), updated: timeCell(table.updatedAt), + location: isSearching + ? { label: folderLocationLabel(table.folderId, folderById, ROOT_LABEL) } + : EMPTY_LOCATION_CELL, }, } }), - [sortedEntries, membersById] + [sortedEntries, membersById, folderById, isSearching] ) /** @@ -579,11 +613,11 @@ export function Tables() { breadcrumbs: folderChain, rootLabel: ROOT_LABEL, rootIcon: FOLDERED_RESOURCE_HEADERS.table.rootIcon, - onNavigate: setCurrentFolderId, + onNavigate: openFolder, currentFolderActions, currentFolderEditing, }), - [folderChain, setCurrentFolderId, currentFolderActions, currentFolderEditing] + [folderChain, openFolder, currentFolderActions, currentFolderEditing] ) const searchConfig: SearchConfig = useMemo( @@ -731,6 +765,22 @@ export function Tables() { return tags }, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter]) + const listState = resourceListState({ + rowCount: rows.length, + isLoading, + isPlaceholderData, + error, + search: debouncedSearchTerm, + filterCount: filterTags.length, + folderId: currentFolderId, + foldersResolved, + }) + + const clearSearchAndFilters = () => { + setSearchTerm('') + void setTableFilters({ rows: null, owner: null }) + } + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -750,12 +800,12 @@ export function Tables() { if (isRowContextMenuOpen || listRename.editingId === rowId) return const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { - setCurrentFolderId(parsed.id) + openFolder(parsed.id) return } router.push(`/workspace/${workspaceId}/tables/${parsed.id}`) }, - [isRowContextMenuOpen, listRename.editingId, router, workspaceId, setCurrentFolderId] + [isRowContextMenuOpen, listRename.editingId, router, workspaceId, openFolder] ) const resolveRowItem = useCallback( @@ -1005,6 +1055,7 @@ export function Tables() { selection: { selectedRowIds, visibleRowIds, replaceSelection }, onSpringOpenFolder: setCurrentFolderId, currentFolderId, + bodyDropFolderId: isSearching ? undefined : currentFolderId, }) const handleDelete = async () => { @@ -1051,9 +1102,11 @@ export function Tables() { id: activeFolder.id, }) // The open folder just disappeared — fall back to its parent rather than - // leaving a `?folderId=` pointing at an archived folder. + // leaving a `?folderId=` pointing at an archived folder. Not `openFolder`: + // this is a forced correction, so it must neither clear an active search nor + // push a back-stack entry aimed at the folder that was just deleted. if (currentFolderId === activeFolder.id) { - setCurrentFolderId(activeFolder.parentId) + setCurrentFolderId(activeFolder.parentId, { history: 'replace' }) } setIsDeleteFolderDialogOpen(false) setActiveFolder(null) @@ -1267,8 +1320,22 @@ export function Tables() { filter={filterConfig} /> + ) : listState === 'no-results' ? ( + + ) : undefined + } selectable={canEdit ? selectableConfig : undefined} rowDragDrop={rowDragDropConfig} onRowClick={handleRowClick} @@ -1335,7 +1402,7 @@ export function Tables() { position={rowContextMenuPosition} onClose={closeRowContextMenu} onOpen={() => { - if (activeFolder) setCurrentFolderId(activeFolder.id) + if (activeFolder) openFolder(activeFolder.id) closeRowContextMenu() }} onRename={() => { diff --git a/apps/sim/components/empty-state/empty-state.tsx b/apps/sim/components/empty-state/empty-state.tsx new file mode 100644 index 00000000000..538f740714e --- /dev/null +++ b/apps/sim/components/empty-state/empty-state.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react' + +interface EmptyStateProps { + title: string + description: string + graphic?: ReactNode + action?: ReactNode +} + +/** + * Shared platform empty-state frame with a visual, concise guidance, and an optional action. + * + * The frame owns the action row's layout so every empty state's chips sit identically — + * callers pass the chips themselves, not a wrapper. + */ +export function EmptyState({ title, description, graphic, action }: EmptyStateProps) { + return ( +
+ {graphic ?
{graphic}
: null} +

{title}

+

+ {description} +

+ {action ?
{action}
: null} +
+ ) +} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 1f9d1b55a6b..01f30f3e399 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1365,25 +1365,30 @@ export const S3Icon = (props: SVGProps) => ( ) +const GOOGLE_ICON_PNG_DATA_URI = + 'data:image/png;base64,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' + +/** + * Google's current "G" mark. + * + * Google publishes this logo only as a raster asset, so the official 200x204 transparent PNG is + * embedded verbatim inside an SVG wrapper to keep the shared `SVGProps` icon API. + * The wrapper's viewBox matches the artwork exactly, which bleeds to all four edges. + * + * @see https://developers.google.com/identity/branding-guidelines + * @see https://developers.google.com/static/identity/images/g-logo.png + */ export function GoogleIcon(props: SVGProps) { return ( - - - - - + + ) } diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts b/apps/sim/components/iso/iso-illustration-style.ts similarity index 59% rename from apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts rename to apps/sim/components/iso/iso-illustration-style.ts index bbb7a06986d..8d0dca0530e 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts +++ b/apps/sim/components/iso/iso-illustration-style.ts @@ -1,4 +1,4 @@ -import type { SVGProps } from 'react' +import type { SVGAttributes, SVGProps } from 'react' export const ISO_LINE_STROKE_WIDTH = 3.2 export const ISO_STROKE = 'color-mix(in srgb, var(--text-subtle) 76%, var(--text-muted))' @@ -15,7 +15,19 @@ export const ISO_FILL_PROPS = { pointerEvents: 'none', } satisfies SVGProps -export function createIsoLineProps(className: string, stroke: string): SVGProps { +/** + * Contour props for an iso face. `strokeWidth` defaults to the shared weight; a mark + * drawn at a different scale passes its own so the recipe stays single-sourced. + * + * Typed as attributes rather than `SVGProps` because it never returns a `ref` — + * that is the only member that would pin it to one element, and these spread onto + * `` and `` alike. + */ +export function createIsoLineProps( + stroke: string, + strokeWidth: number = ISO_LINE_STROKE_WIDTH, + className?: string +): SVGAttributes { return { className, fill: 'none', @@ -23,7 +35,7 @@ export function createIsoLineProps(className: string, stroke: string): SVGProps< pointerEvents: 'none', opacity: 1, stroke, - strokeWidth: ISO_LINE_STROKE_WIDTH, + strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round', } diff --git a/apps/sim/connectors/onedrive/onedrive.ts b/apps/sim/connectors/onedrive/onedrive.ts index 63a427d59d5..6e25767f5ef 100644 --- a/apps/sim/connectors/onedrive/onedrive.ts +++ b/apps/sim/connectors/onedrive/onedrive.ts @@ -8,6 +8,7 @@ import { ConnectorFileTooLargeError, connectorFileExtension, extractConnectorText, + hasIndexablePayload, isIndexableConnectorFile, isSkippedDocument, markSkipped, @@ -383,7 +384,7 @@ export const onedriveConnector: ConnectorConfig = { try { const payload = await fetchFilePayload(accessToken, item.id, item.name) - if (!payload.sourceFile && !payload.content.trim()) return null + if (!hasIndexablePayload(payload)) return null const stub = fileToStub(item) return { ...stub, ...payload, contentDeferred: false } diff --git a/apps/sim/connectors/sharepoint/sharepoint.ts b/apps/sim/connectors/sharepoint/sharepoint.ts index 3ebd325555b..8a7fb352d29 100644 --- a/apps/sim/connectors/sharepoint/sharepoint.ts +++ b/apps/sim/connectors/sharepoint/sharepoint.ts @@ -8,6 +8,7 @@ import { ConnectorFileTooLargeError, connectorFileExtension, extractConnectorText, + hasIndexablePayload, isIndexableConnectorFile, isSkippedDocument, markSkipped, @@ -931,7 +932,7 @@ export const sharepointConnector: ConnectorConfig = { try { const payload = await fetchFilePayload(accessToken, driveId, item.id, item.name) - if (!payload.sourceFile && !payload.content.trim()) return null + if (!hasIndexablePayload(payload)) return null const stub = itemToStub(item, siteName ?? siteUrl) return { ...stub, ...payload, contentDeferred: false } diff --git a/apps/sim/connectors/utils.test.ts b/apps/sim/connectors/utils.test.ts index 6ba40e5200a..ca786fadc1d 100644 --- a/apps/sim/connectors/utils.test.ts +++ b/apps/sim/connectors/utils.test.ts @@ -63,6 +63,7 @@ import { typeformConnector } from '@/connectors/typeform/typeform' import { ConnectorFileTooLargeError, extractConnectorText, + hasIndexablePayload, htmlToPlainText, isIndexableConnectorFile, isSkippedDocument, @@ -1482,3 +1483,33 @@ describe('pipelineParsedMimeType', () => { expect(pipelineParsedMimeType('README')).toBeUndefined() }) }) + +describe('hasIndexablePayload', () => { + const bytes = (value: string) => ({ + bytes: Buffer.from(value), + fileName: 'Report.pdf', + mimeType: 'application/pdf', + }) + + it('accepts a source file with bytes', () => { + expect(hasIndexablePayload({ content: '', sourceFile: bytes('%PDF') })).toBe(true) + }) + + it('accepts extracted text', () => { + expect(hasIndexablePayload({ content: 'notes' })).toBe(true) + }) + + /** + * Observed in production: a zero-byte PDF was stored and shipped to OCR, which + * answered `400 Bad Request` — an external call billed to discover the file was + * empty, reported as an API fault rather than as an empty file. Before source + * files existed this was dropped at the empty-content check. + */ + it('rejects a zero-byte source file rather than sending it to OCR', () => { + expect(hasIndexablePayload({ content: '', sourceFile: bytes('') })).toBe(false) + }) + + it('rejects blank text', () => { + expect(hasIndexablePayload({ content: ' ' })).toBe(false) + }) +}) diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index 608de615bd1..63674ae350c 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -226,6 +226,21 @@ export function isIndexableConnectorFile(fileName: string): boolean { return extension !== undefined && CONNECTOR_INDEXABLE_EXTENSIONS.has(extension) } +/** + * Whether a document carries anything worth indexing. + * + * A source file has to have bytes. A zero-byte file is not payload: it produces an + * empty stored object, and for a PDF that reaches OCR as an empty request and comes + * back as an opaque `400 Bad Request` — billing an external call to learn the file + * was empty, and reporting it as an API fault rather than as what it is. + */ +export function hasIndexablePayload( + doc: Pick +): boolean { + if (doc.sourceFile) return doc.sourceFile.bytes.length > 0 + return doc.content.trim().length > 0 +} + /** * MIME type to store a file under when the shared pipeline should parse it, or * `undefined` when the connector should decode it as text itself. diff --git a/apps/sim/content/library/ai-agent-orchestration-frameworks-explained/index.mdx b/apps/sim/content/library/ai-agent-orchestration-frameworks-explained/index.mdx new file mode 100644 index 00000000000..87fdc1afc48 --- /dev/null +++ b/apps/sim/content/library/ai-agent-orchestration-frameworks-explained/index.mdx @@ -0,0 +1,121 @@ +--- +slug: ai-agent-orchestration-frameworks-explained +title: 'AI Agent Orchestration Frameworks Explained' +description: 'Learn how AI agent orchestration frameworks coordinate models, tools, state, and control flow, and compare code-first, visual, and provider-native approaches.' +date: 2026-08-19 +updated: 2026-08-19 +authors: + - andrew +readingTime: 8 +tags: [AI Agents, Agent Orchestration, Workflow Automation, Sim] +ogImage: /library/ai-agent-orchestration-frameworks-explained/cover.jpg +canonical: https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained +draft: false +faq: + - q: "How does agent orchestration differ from workflow automation?" + a: "Agent orchestration coordinates model reasoning, tool use, state, and control flow across one or more agents. Workflow automation primarily executes predefined steps. Modern systems can combine both approaches: models handle decisions that require context, while deterministic branches, loops, validations, and approvals govern predictable or sensitive actions." + - q: "Do agent orchestration frameworks require code?" + a: "Agent orchestration frameworks may use code, visual graphs, or natural-language instructions. Sim supports a visual canvas, natural-language building through Mothership, and programmatic interfaces. You can choose direct code control or build faster without writing every component yourself." + - q: "How does MCP fit into agent deployment?" + a: "MCP provides a standard way for AI systems to discover and call external tools. Sim can connect to external MCP servers and publish deployed workflows as MCP tools. You can make one workflow available to compatible assistants without building a separate integration for each one." + - q: "How do open-source, fair-code, and proprietary licenses differ?" + a: "Open-source licenses generally permit inspection, modification, and redistribution under stated conditions. Fair-code licenses make source available but may restrict some commercial uses, while proprietary licenses reserve broader control for the vendor. Review the exact license and enterprise terms before embedding a framework, offering it commercially, modifying it internally, or committing to a hosting model." +--- + +## TL;DR + +An AI agent orchestration framework is software that coordinates agents, models, tools, state, and control flow so they can complete multi-step work. + +- **Code-first frameworks** such as [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) give you direct control over orchestration logic in code. +- **Model-vendor-native stacks** such as the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) integrate closely with one provider's models and tooling. +- **Visual and natural-language builders** such as Sim let you combine agent reasoning, explicit workflow logic, and code in one graph. Sim sits in this visual and code hybrid category. + +[Start building with Sim](https://sim.ai). + +## What an AI agent orchestration framework is + +An AI agent orchestration framework is software for defining how AI agents, models, tools, and human input work together across a task. It coordinates each step while preserving context, routing work, and controlling what happens next. + +Orchestration adds a runtime that single prompts and plain automation lack. A single prompt usually produces one response, while conventional automation follows a fixed sequence of predefined actions. An orchestration runtime tracks state across steps and uses a graph to represent the available actions and transitions. This combination is also central to an [agentic workflow](https://www.sim.ai/library/what-is-an-agentic-workflow). + +Most frameworks provide four core primitives. State stores messages, tool outputs, and other context that later steps need. Tool calling lets an agent query data or act through external systems. Control-flow operators route work and repeat steps, while approval nodes pause execution for human review. A deployment surface exposes the completed workflow through an API, chat interface, MCP server, or background job. + +Builders usually choose an approach based on how much infrastructure and control they want to own. Code-first frameworks expose orchestration logic directly, while visual and natural-language builders package it into an editor and managed runtime. Model-vendor-native stacks offer tighter integration with one provider's models and tool conventions. + +## The three ways teams build agent orchestration + +Code-first frameworks give you detailed control over agent state and execution, but you must write and maintain the surrounding infrastructure. + +Visual and natural-language builders speed up development while preserving varying levels of control over branches, loops, and approvals. + +Model-vendor-native stacks provide tight integration with one provider's models and tools, but they can limit model choice and increase ecosystem lock-in. + +### Code-first frameworks: LangGraph and CrewAI + +Code-first frameworks let engineers express agent orchestration directly in software, which gives them detailed control over state, routing, retries, and tool calls. They suit projects that need custom runtime behavior or must fit an existing application architecture. Engineers also gain access to normal development practices such as version control, automated tests, and code review. + +[LangGraph models an agent workflow as a stateful graph](https://docs.langchain.com/oss/python/langgraph/graph-api). Nodes run model calls, tools, or application logic, while edges determine which node runs next. A shared state object carries information between nodes. Because the graph can contain branches and cycles, LangGraph supports repeated reasoning, [human-in-the-loop interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts), and long-running processes without forcing every decision into one prompt. + +[CrewAI organizes multi-agent orchestration](https://docs.crewai.com/en/concepts/crews) around agents with defined roles, goals, and tools. A crew assigns tasks to agents and controls how they collaborate, while [CrewAI Flows](https://docs.crewai.com/en/concepts/flows) add event-driven steps and state management around that collaboration. The role-based model works well when a workflow maps naturally to specialists, such as a researcher passing findings to a writer. + +The open-source frameworks do not provide a complete application by themselves. You may still need to assemble an end-user interface, deployment infrastructure, authentication, integrations, and production monitoring. [LangGraph Platform](https://docs.langchain.com/langgraph-platform/overview) and [CrewAI Enterprise](https://docs.crewai.com/en/enterprise/introduction) offer related services and tooling, but adopting those products introduces additional architecture and operating choices. + +Sim takes a different approach by packaging visual and natural-language building, deployment, and execution logs in one workspace. That approach can shorten the path between a prototype and an inspectable deployed system, while code-first frameworks preserve more control over implementation details. For a closer look at the tradeoffs, see these [LangGraph alternatives](https://www.sim.ai/library/langgraph-alternatives). + +### Visual and natural-language workflow builders: n8n, Make, Zapier, and Sim + +Visual and natural-language workflow builders let you assemble orchestration on a canvas instead of defining every state transition in code. You connect triggers, actions, branches, loops, and AI steps as blocks. Some products also let you create or edit those blocks through natural-language instructions. + +[n8n](https://docs.n8n.io/flow-logic/) and [Make](https://help.make.com/scenario-editor) center their builders on visual app automation, while [Zapier](https://help.zapier.com/hc/en-us/articles/8496181725453-Create-Zaps) uses trigger-and-action workflows. A trigger starts a predefined sequence, and each node processes data or calls another application. Their respective [AI Agent](https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/), [Make AI Agents](https://help.make.com/make-ai-agents-new), and [Zapier Agents](https://help.zapier.com/hc/en-us/articles/30219476645645-Create-an-agent-in-Zapier-Agents) features add model decisions, tool use, and generated outputs within those products. Explicit flow controls govern what happens around each AI step. + +Sim gives model reasoning a first-class role within the same graph as deterministic control. A [Sim workflow](https://docs.sim.ai/introduction) can combine Agent blocks with functions, conditions, routers, loops, parallel branches, integration actions, and human approval. You can therefore reserve model judgment for tasks such as classifying a request while using fixed logic for validation, routing, and sensitive actions. + +[Mothership](https://docs.sim.ai/mothership) adds natural-language control across the Sim workspace. You can ask it to create or modify workflows, work with tables and files, connect knowledge bases, and set up recurring jobs. The visual canvas remains available for inspecting the resulting graph and adjusting individual blocks. + +Sim also connects orchestration to deployment. According to its [deployment documentation](https://docs.sim.ai/workflows/deployment), a versioned workflow can run through a REST API or hosted chat, and its [MCP deployment](https://docs.sim.ai/workflows/deployment/mcp) can expose a deployed workflow as a tool. Those options let one graph serve an application backend, a conversational interface, or another AI system without rebuilding its orchestration logic for each surface. + +Choose this category when you want faster construction and easier inspection than a code-first framework provides. The main differences concern how deeply each builder supports agent reasoning, how much deterministic control remains available, and how you can deploy the finished workflow. This [OpenAI, n8n, and Sim comparison](https://www.sim.ai/library/openai-vs-n8n-vs-sim) examines those distinctions in more detail. + +### Model-vendor-native stacks: OpenAI Agents SDK + +Provider-native stacks package agent orchestration around one company's models, APIs, and tool conventions. The [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) combines function tools, built-in tools, agent handoffs, guardrails, sessions, and tracing. You define agent instructions and available tools, while the stack manages model calls and the loop that selects and executes those tools. + +OpenAI maintains the models and orchestration interfaces together, so new model capabilities can reach the OpenAI Agents SDK without a separate compatibility layer. The SDK also lets you add application logic around agent runs, including [conditional routing and handoffs](https://openai.github.io/openai-agents-python/handoffs/) between specialized agents. + +The main tradeoff concerns model and deployment choice. An application built around OpenAI-specific tools, response objects, and tracing requires extra work to support another provider or a self-hosted model. You still need to host the surrounding application unless a separate OpenAI product supplies the user interface or runtime surface. + +You may choose the OpenAI Agents SDK when your application already standardizes on OpenAI models and you want direct access to their tool-calling behavior. A model-neutral framework usually fits better when you expect to switch providers, run local models, or deploy the same workflow across several runtime environments. + +## Best AI agent orchestration frameworks in 2026 + +The table compares seven prominent orchestration options across five decision criteria: builder model, agent depth, deterministic control, deployment surfaces, and self-hosting or license model. + +The product capabilities and license classifications below are current as of August 2026. LangGraph and CrewAI use the permissive MIT license; n8n uses its fair-code Sustainable Use License; Sim's core uses Apache 2.0; and the OpenAI Agents SDK is MIT-licensed, while related hosted OpenAI services remain proprietary. Make and Zapier are proprietary cloud products. Review the linked license or terms pages for the current legal text before making a procurement or distribution decision. + +| Framework | Builder model | Agent depth | Deterministic control | Deployment surfaces | Self-hosting and license | +| --- | --- | --- | --- | --- | --- | +| [LangGraph](https://docs.langchain.com/oss/python/langgraph/graph-api) | ✅ Code-first graph | ✅ Stateful agent runtime | ✅ Nodes, branches, loops | 🟡 App or API | ✅ Self-hosted, [MIT](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) | +| [CrewAI](https://docs.crewai.com/en/concepts/crews) | ✅ Code-first roles and tasks | ✅ Multi-agent runtime | 🟡 [Flows and task routing](https://docs.crewai.com/en/concepts/flows) | 🟡 App or API | ✅ Self-hosted, [MIT](https://github.com/crewAIInc/crewAI/blob/main/LICENSE) | +| [n8n](https://docs.n8n.io/advanced-ai/) | ✅ Visual workflow canvas | 🟡 Agents within automation | ✅ [Branches and loops](https://docs.n8n.io/flow-logic/) | ✅ [Webhooks](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/), [API](https://docs.n8n.io/api/), [chat](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.chattrigger/) | 🟡 Self-hosted, [fair-code Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | +| [Make](https://help.make.com/scenario-editor) | ✅ Visual workflow canvas | 🟡 [AI inside automation](https://help.make.com/make-ai-agents-new) | ✅ [Routes, filters, iterators](https://help.make.com/router) | 🟡 [Webhooks](https://help.make.com/webhooks) and [API](https://developers.make.com/api-documentation) | ❌ [Proprietary cloud](https://www.make.com/en/terms-and-conditions) | +| [Zapier](https://help.zapier.com/hc/en-us/articles/8496181725453-Create-Zaps) | ✅ Visual and natural language | 🟡 [Agents plus app automation](https://help.zapier.com/hc/en-us/articles/30219476645645-Create-an-agent-in-Zapier-Agents) | 🟡 [Paths](https://help.zapier.com/hc/en-us/articles/8496277737997-Add-branching-logic-to-Zaps-with-paths) and [approval steps](https://zapier.com/apps/approval/integrations) | 🟡 [Apps](https://zapier.com/apps), [agent chat](https://help.zapier.com/hc/en-us/articles/30219476645645-Create-an-agent-in-Zapier-Agents), [webhooks](https://help.zapier.com/hc/en-us/articles/8496326446989-Send-webhooks-in-Zaps) | ❌ [Proprietary cloud](https://zapier.com/tos) | +| Sim | ✅ Natural language, visual, API | ✅ Agent-native runtime | ✅ Branches, loops, approvals | ✅ [API, hosted chat, MCP](https://docs.sim.ai/workflows/deployment) | ✅ Self-hosted, [Apache 2.0 core](https://github.com/simstudioai/sim) | +| [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) | ✅ Code-first SDK | ✅ Native agents and handoffs | 🟡 Code-defined routing and guardrails | 🟡 App-managed APIs and interfaces | 🟡 [MIT-licensed SDK](https://github.com/openai/openai-agents-python/blob/main/LICENSE), proprietary services | + +## Choosing the right orchestration approach + +Code-first frameworks suit you when orchestration behavior must live in your application code. [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) and [CrewAI](https://docs.crewai.com/) let engineers define state, agent roles, tool calls, and failure handling directly. You gain low-level control, but you must often assemble deployment, interfaces, and monitoring separately. + +Visual and natural-language builders suit you when build speed and operational visibility take priority. Platforms such as [n8n](https://docs.n8n.io/), [Make](https://help.make.com/), [Zapier](https://help.zapier.com/hc/en-us), and Sim expose workflow logic on a canvas. Compare their documented agent runtime depth, approval controls, and production surfaces. For example, Sim's [deployment surfaces](https://docs.sim.ai/workflows/deployment) include an API and hosted chat, while its [MCP support](https://docs.sim.ai/workflows/deployment/mcp) can publish a deployed workflow as a tool. + +Model-vendor-native stacks suit you when your application already depends on one provider. The [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) closely supports OpenAI models and tool-calling conventions, but changing providers may require more rework. + +Licensing can decide the category before features do. Permissive open-source licenses such as [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) provide broad rights to modify and distribute software. Fair-code licenses may restrict commercial use, while proprietary products keep source code and hosting control with the vendor. Check the exact license and enterprise terms if you need self-hosting, internal modifications, or resale rights. For a broader selection, review these [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). + +## Where Sim fits in this landscape + +Sim combines agent reasoning and deterministic control in one visual workflow graph. You can place model decisions beside explicit branches, code, and human approvals, which keeps uncertain tasks within defined operating rules. The same versioned workflow can run through an API or hosted chat. Sim can also [publish it as an MCP tool](https://docs.sim.ai/workflows/deployment/mcp). + +Sim's core uses the permissive [Apache 2.0 license](https://github.com/simstudioai/sim), which supports inspection, modification, and self-hosting. Readers evaluating that final deployment option can follow this guide to [turn a workflow into a reusable MCP tool](https://www.sim.ai/library/how-to-turn-a-workflow-into-a-reusable-mcp-tool). + +To build your first workflow, follow the [Sim documentation quickstart](https://docs.sim.ai/quickstart). diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index fa24cec1bfa..42e1890159f 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -24,6 +24,17 @@ import { executeTool } from '@/tools' const mockExecuteTool = executeTool as ReturnType const mockCollectBlockData = collectBlockData as ReturnType + +/** The handler evaluates every testable branch in one call, so a whole condition list resolves to a single verdict. */ +const matchedAt = (index: number) => ({ + success: true, + output: { result: { matchedIndex: index } }, +}) +const noMatch = () => ({ success: true, output: { result: { matchedIndex: -1 } } }) +const threwAt = (index: number, message: string) => ({ + success: true, + output: { result: { matchedIndex: -1, threwAtIndex: index, message } }, +}) const mockConditionLogger = vi.mocked(loggerMock.createLogger).mock.results[ vi .mocked(loggerMock.createLogger) @@ -123,8 +134,8 @@ describe('ConditionBlockHandler', () => { vi.clearAllMocks() - // Default: condition evaluates to false (else path). Individual tests override with mockResolvedValueOnce. - mockExecuteTool.mockResolvedValue({ success: true, output: { result: false } }) + // Default: no branch matches (else path). Individual tests override with mockResolvedValueOnce. + mockExecuteTool.mockResolvedValue(noMatch()) }) it('should handle condition blocks', () => { @@ -135,7 +146,7 @@ describe('ConditionBlockHandler', () => { it('should execute condition block correctly and select first path', async () => { // Mock executeTool to return true for the condition - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [ { id: 'cond1', title: 'if', value: 'context.value > 5' }, @@ -162,7 +173,7 @@ describe('ConditionBlockHandler', () => { }) it('should pass correct parameters to function_execute tool', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [ { id: 'cond1', title: 'if', value: 'context.value > 5' }, @@ -195,7 +206,7 @@ describe('ConditionBlockHandler', () => { blockData: { 'huge-block': { payload: 'x'.repeat(1024) } }, blockNameMapping: { hugeblock: 'huge-block' }, }) - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [ { id: 'cond1', title: 'if', value: 'true' }, @@ -209,7 +220,7 @@ describe('ConditionBlockHandler', () => { }) it('should select the else path if other conditions fail', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'context.value < 0' }, @@ -247,7 +258,7 @@ describe('ConditionBlockHandler', () => { }) it('finds whitespace and mixed-case else branches during fallback', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'false' }, @@ -282,7 +293,7 @@ describe('ConditionBlockHandler', () => { it('should handle evaluation errors gracefully', async () => { const secret = 'condition-runtime-secret-value' - mockExecuteTool.mockResolvedValueOnce({ + mockExecuteTool.mockResolvedValue({ success: false, error: `Cannot read ${secret} through __var_API_KEY`, }) @@ -300,8 +311,25 @@ describe('ConditionBlockHandler', () => { expect(JSON.stringify(mockConditionLogger.error.mock.calls)).not.toContain('__var_API_KEY') }) + it('names the branch an expression threw on without leaking the failure into logs', async () => { + const secret = 'condition-throw-secret-value' + mockExecuteTool.mockResolvedValueOnce(threwAt(1, `Cannot read ${secret}`)) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value > 5' }, + { id: 'cond2', title: 'else if', value: 'context.missing.deep()' }, + { id: 'else1', title: 'else', value: '' }, + ] + + await expect( + handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(conditions) }) + ).rejects.toThrow(/Evaluation error in condition "else if": Cannot read/) + expect(mockExecuteTool).toHaveBeenCalledOnce() + expect(JSON.stringify(mockConditionLogger.error.mock.calls)).not.toContain(secret) + }) + it('should handle missing source block output gracefully', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [{ id: 'cond1', title: 'if', value: 'true' }] const inputs = { conditions: JSON.stringify(conditions) } @@ -318,7 +346,7 @@ describe('ConditionBlockHandler', () => { }) it('should throw error if target block is missing', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [{ id: 'cond1', title: 'if', value: 'true' }] const inputs = { conditions: JSON.stringify(conditions) } @@ -331,8 +359,7 @@ describe('ConditionBlockHandler', () => { }) it('should return no-match result if no condition matches and no else exists', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'false' }, @@ -358,7 +385,7 @@ describe('ConditionBlockHandler', () => { }) it('falls back to else path when loop context data is unavailable', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'context.item === "apple"' }, @@ -373,7 +400,7 @@ describe('ConditionBlockHandler', () => { }) it('should use collectBlockData to gather block state', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [ { id: 'cond1', title: 'if', value: 'true' }, @@ -403,9 +430,166 @@ describe('ConditionBlockHandler', () => { ) }) + describe('Batched evaluation', () => { + const manyConditions = [ + { id: 'cond1', title: 'if', value: 'context.value === 1' }, + { id: 'cond2', title: 'else if', value: 'context.value === 2' }, + { id: 'cond3', title: 'else if', value: 'context.value === 3' }, + { id: 'cond4', title: 'else if', value: 'context.value === 4' }, + { id: 'else1', title: 'else', value: '' }, + ] + + it('evaluates every testable branch in a single call', async () => { + mockExecuteTool.mockResolvedValueOnce(noMatch()) + + await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(manyConditions), + }) + + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + + it('tests the expressions in declaration order and stops at the first truthy one', async () => { + mockExecuteTool.mockResolvedValueOnce(noMatch()) + + await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(manyConditions), + }) + + const [, toolParams] = mockExecuteTool.mock.calls[0] + const code = toolParams.code as string + const positions = manyConditions.slice(0, 4).map((condition) => code.indexOf(condition.value)) + + expect(positions.every((position) => position >= 0)).toBe(true) + expect(positions).toEqual([...positions].sort((a, b) => a - b)) + // The else branch carries no expression and must never reach the sandbox. + expect(code).toContain('return { matchedIndex: -1 }') + }) + + it('never sends the else branch for evaluation', async () => { + mockExecuteTool.mockResolvedValueOnce(noMatch()) + + await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify([ + { id: 'cond1', title: 'if', value: 'context.value === 1' }, + { id: 'else1', title: 'else', value: 'SHOULD_NEVER_BE_EVALUATED' }, + { id: 'cond2', title: 'else if', value: 'context.value === 2' }, + ]), + }) + + const [, toolParams] = mockExecuteTool.mock.calls[0] + expect(toolParams.code).not.toContain('SHOULD_NEVER_BE_EVALUATED') + expect(toolParams.code).not.toContain('context.value === 2') + }) + + it('re-evaluates one branch at a time when the batch returns no verdict', async () => { + // A syntax error anywhere fails the whole script at parse time, so the + // fallback must still take the branch an earlier condition matches. + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'SyntaxError: Unexpected identifier', + }) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + + const result = await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(manyConditions), + }) + + expect((result as any).selectedOption).toBe('cond1') + expect(mockExecuteTool).toHaveBeenCalledTimes(2) + }) + + it('emits a batch script a trailing line comment cannot break', async () => { + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) + + await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify([ + { id: 'cond1', title: 'if', value: 'context.value > 5 // gate' }, + { id: 'else1', title: 'else', value: '' }, + ]), + }) + + const [, toolParams] = mockExecuteTool.mock.calls[0] + // Compiling is the assertion: a comment that swallowed the closing + // parenthesis would fail the whole script at parse time. + expect(() => new Function(toolParams.code as string)).not.toThrow() + }) + + it('wraps a comment-bearing expression the same way when falling back', async () => { + // The fallback recovers a batch the sandbox could not parse, so it has to + // accept every expression the batch accepts — otherwise the recovery path + // rejects a branch the primary path would have matched. + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'Invalid JavaScript syntax: Unexpected token', + }) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + + const result = await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify([ + { id: 'cond1', title: 'if', value: 'context.value > 5 // gate' }, + { id: 'cond2', title: 'else if', value: 'context.value ===' }, + { id: 'else1', title: 'else', value: '' }, + ]), + }) + + expect((result as any).selectedOption).toBe('cond1') + const [, fallbackParams] = mockExecuteTool.mock.calls[1] + expect(() => new Function(fallbackParams.code as string)).not.toThrow() + }) + + it('does not fan a timed-out batch out into one call per branch', async () => { + mockExecuteTool.mockResolvedValue({ + success: false, + error: 'Request timed out after 5000ms', + }) + + await expect( + handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(manyConditions) }) + ).rejects.toThrow(/Evaluation error in condition "if".*Request timed out/) + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + + it('falls back when the batch reports a branch index outside the list', async () => { + mockExecuteTool.mockResolvedValueOnce(matchedAt(99)) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + + const result = await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(manyConditions), + }) + + expect((result as any).selectedOption).toBe('cond1') + expect(mockExecuteTool).toHaveBeenCalledTimes(2) + }) + + it('does not take the else path on a reply that carries no verdict', async () => { + // Reading a garbled reply as "nothing matched" would silently reroute the + // run, so an unrecognized shape has to fall back rather than fall through. + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: { ok: true } } }) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + + const result = await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(manyConditions), + }) + + expect((result as any).selectedOption).toBe('cond1') + expect(mockExecuteTool).toHaveBeenCalledTimes(2) + }) + + it('does not retry per branch once the run has been cancelled', async () => { + mockContext.abortSignal = AbortSignal.abort() + mockExecuteTool.mockResolvedValue({ success: false, error: 'Execution cancelled' }) + + await expect( + handler.execute(mockContext, mockBlock, { conditions: JSON.stringify(manyConditions) }) + ).rejects.toThrow(/Evaluation error in condition "if".*Execution cancelled/) + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + }) + describe('Multiple branches to same target', () => { it('should handle if and else pointing to same target', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [ { id: 'cond1', title: 'if', value: 'context.value > 5' }, @@ -431,7 +615,7 @@ describe('ConditionBlockHandler', () => { }) it('should select else branch to same target when if fails', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'context.value < 0' }, @@ -457,10 +641,8 @@ describe('ConditionBlockHandler', () => { }) it('should handle if→A, elseif→B, else→A pattern', async () => { - // First condition (cond1): false - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) - // Second condition (cond2): false - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + // Neither cond1 nor cond2 matches, so the else branch wins. + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'context.value === 1' }, @@ -486,7 +668,7 @@ describe('ConditionBlockHandler', () => { describe('Condition evaluation with different data types', () => { it('should evaluate string comparison conditions', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { name: 'test', status: 'active' }, @@ -506,7 +688,7 @@ describe('ConditionBlockHandler', () => { }) it('should evaluate boolean conditions', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { isEnabled: true, count: 5 }, @@ -526,7 +708,7 @@ describe('ConditionBlockHandler', () => { }) it('should evaluate array length conditions', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { items: [1, 2, 3, 4, 5] }, @@ -546,7 +728,7 @@ describe('ConditionBlockHandler', () => { }) it('should evaluate null/undefined check conditions', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { data: null }, @@ -568,8 +750,7 @@ describe('ConditionBlockHandler', () => { describe('Multiple else-if conditions', () => { it('should evaluate multiple else-if conditions in order', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(1)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { score: 75 }, @@ -612,9 +793,7 @@ describe('ConditionBlockHandler', () => { }) it('should skip to else when all else-if fail', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { score: 30 }, @@ -638,7 +817,7 @@ describe('ConditionBlockHandler', () => { describe('Condition with no outgoing edge', () => { it('should set selectedOption when condition matches but has no edge', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [ { id: 'cond1', title: 'if', value: 'true' }, @@ -660,7 +839,7 @@ describe('ConditionBlockHandler', () => { }) it('should set selectedOption when else is selected but has no edge', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'false' }, @@ -682,7 +861,7 @@ describe('ConditionBlockHandler', () => { }) it('should deactivate if-path when else is selected with no edge', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const conditions = [ { id: 'cond1', title: 'if', value: 'context.value > 100' }, @@ -715,7 +894,7 @@ describe('ConditionBlockHandler', () => { }) it('should handle conditions passed as array directly', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const conditions = [ { id: 'cond1', title: 'if', value: 'true' }, @@ -731,7 +910,7 @@ describe('ConditionBlockHandler', () => { describe('Source output filtering', () => { it('should not propagate error field from source block output', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { value: 10, text: 'hello', error: 'upstream block failed' }, @@ -753,7 +932,7 @@ describe('ConditionBlockHandler', () => { }) it('should not propagate _pauseMetadata from source block output', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { value: 10, _pauseMetadata: { contextId: 'abc' } }, @@ -774,7 +953,7 @@ describe('ConditionBlockHandler', () => { }) it('should still pass through non-control fields from source output', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) ;(mockContext.blockStates as any).set(mockSourceBlock.id, { output: { value: 10, text: 'hello', customData: { nested: true } }, @@ -798,7 +977,7 @@ describe('ConditionBlockHandler', () => { describe('Virtual block ID handling', () => { it('should use currentVirtualBlockId for decision key when available', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) mockContext.currentVirtualBlockId = 'virtual-block-123' @@ -817,7 +996,7 @@ describe('ConditionBlockHandler', () => { describe('Parallel branch handling', () => { it('should resolve connections and block data correctly when inside a parallel branch', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const parallelConditionBlock: SerializedBlock = { id: 'cond-block-1₍0₎', @@ -897,7 +1076,7 @@ describe('ConditionBlockHandler', () => { }) it('should find correct source block output in parallel branch context', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } }) + mockExecuteTool.mockResolvedValueOnce(matchedAt(0)) const parallelConditionBlock: SerializedBlock = { id: 'cond-block-1₍1₎', @@ -969,7 +1148,7 @@ describe('ConditionBlockHandler', () => { }) it('should fall back to else when condition is false in parallel branch', async () => { - mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + mockExecuteTool.mockResolvedValueOnce(noMatch()) const parallelConditionBlock: SerializedBlock = { id: 'cond-block-1₍2₎', diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts index d21702ae81f..671fbc73eca 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records' import { isElseConditionTitle } from '@/lib/workflows/conditions' import type { BlockOutput } from '@/blocks/types' @@ -13,72 +14,224 @@ import { } from '@/executor/utils/subflow-utils' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' +import type { ToolResponse } from '@/tools/types' const logger = createLogger('ConditionBlockHandler') const CONDITION_TIMEOUT_MS = 5000 +interface ConditionEntry { + id: string + title: string + value: string +} + +/** Verdict for a whole condition list evaluated in one function execution. */ +type ConditionEvaluation = + | { status: 'matched'; index: number } + | { status: 'no-match' } + | { status: 'expression-threw'; index: number; message: string } + | { status: 'no-verdict'; message: string; timedOut: boolean } + /** - * Evaluates a single condition expression. - * The resolver preserves legacy Condition expression substitution before this function executes the - * resulting JavaScript through the shared function execution boundary. + * Wraps one expression as a boolean test, on its own line so a trailing line + * comment ends before the closing parenthesis instead of swallowing it. * - * `blockData` is deliberately empty: the resolver already inlines every `` reference - * into the expression before this runs, so shipping the run's accumulated block outputs would only - * inflate the request body. Sending them blew the 10MB body cap on wide subflows, where a single - * flat `blockStates` map holds every branch's outputs. + * The batched script and the per-branch fallback both wrap through here. Wrapping + * them separately let the two drift: an expression carrying a trailing comment + * parsed in the batch and failed in the fallback, so the recovery path rejected + * a branch the primary path accepted. + */ +function buildBooleanTest(expression: string): string { + return `Boolean(\n${expression}\n)` +} + +/** + * Builds one script that tests each expression in order and reports the index of + * the first truthy one. * - * Returns true if condition is met, false otherwise. + * Ordering and short-circuiting match evaluating the expressions one call at a + * time: an expression is only reached once every earlier one returned falsy, so + * a later expression that throws is still never reached and the run takes the + * same branch it takes today. The `catch` reports the index it was on as data + * rather than rethrowing, which is what lets the caller name the failing branch. */ -async function evaluateConditionExpression( +function buildConditionScript(expressions: string[], evalContext: Record): string { + const tests = expressions + .map( + (expression, index) => + ` __simConditionIndex = ${index}\n` + + ` if (${buildBooleanTest(expression)}) return { matchedIndex: ${index} }` + ) + .join('\n') + + return [ + `const context = ${JSON.stringify(evalContext)};`, + 'let __simConditionIndex = -1', + 'try {', + tests, + ' return { matchedIndex: -1 }', + '} catch (__simConditionError) {', + ' return {', + ' matchedIndex: -1,', + ' threwAtIndex: __simConditionIndex,', + ' message:', + ' __simConditionError && __simConditionError.message', + ' ? String(__simConditionError.message)', + ' : String(__simConditionError),', + ' }', + '}', + ].join('\n') +} + +/** + * Runs condition code through the shared function execution boundary. + * + * `blockData` is deliberately empty: the resolver already inlines every + * `` reference into the expression before this runs, so shipping + * the run's accumulated block outputs would only inflate the request body. + * Sending them blew the 10MB body cap on wide subflows, where a single flat + * `blockStates` map holds every branch's outputs. + */ +async function runConditionCode( ctx: ExecutionContext, - conditionExpression: string, - providedEvalContext?: Record, + code: string, currentNodeId?: string -): Promise { - const evalContext = providedEvalContext || {} - - try { - const contextSetup = `const context = ${JSON.stringify(evalContext)};` - const code = `${contextSetup}\nreturn Boolean(${conditionExpression})` - - const { blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId) - - const result = await executeTool( - 'function_execute', - { - code, - timeout: CONDITION_TIMEOUT_MS, - envVars: normalizeStringRecord(ctx.environmentVariables), - workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables), - blockData: {}, - blockNameMapping, - blockOutputSchemas, - _context: { - workflowId: ctx.workflowId, - workspaceId: ctx.workspaceId, - userId: ctx.userId, - isDeployedContext: ctx.isDeployedContext, - enforceCredentialAccess: ctx.enforceCredentialAccess, - }, +): Promise { + const { blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId) + + return executeTool( + 'function_execute', + { + code, + timeout: CONDITION_TIMEOUT_MS, + envVars: normalizeStringRecord(ctx.environmentVariables), + workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables), + blockData: {}, + blockNameMapping, + blockOutputSchemas, + _context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + userId: ctx.userId, + isDeployedContext: ctx.isDeployedContext, + enforceCredentialAccess: ctx.enforceCredentialAccess, }, - { executionContext: ctx } - ) + }, + { executionContext: ctx } + ) +} - if (!result.success) { - logger.error('Failed to evaluate condition', { - hasRuntimeError: Boolean(result.error), - }) - throw new Error(`Evaluation error in condition: ${result.error}`) +/** + * A failed batch normally falls back to one call per condition, which is exactly + * what this handler did before batching. A timeout is the one failure where that + * is the wrong move: the fallback would re-run every remaining condition against + * the same stalled transport, turning one slow call into as many slow calls as + * there are branches. Classified by message because the transport reports giving + * up as text, the same way `isRetryableFailure` does in `@/tools`. + */ +function isTimeoutFailure(error: string | undefined): boolean { + if (!error) return false + const message = error.toLowerCase() + return message.includes('timed out') || message.includes('timeout') +} + +/** Evaluates the whole condition list in a single function execution. */ +async function evaluateConditionList( + ctx: ExecutionContext, + expressions: string[], + evalContext: Record, + currentNodeId?: string +): Promise { + const result = await runConditionCode( + ctx, + buildConditionScript(expressions, evalContext), + currentNodeId + ) + + if (!result.success) { + const message = result.error ?? 'Condition evaluation failed' + return { status: 'no-verdict', message, timedOut: isTimeoutFailure(result.error) } + } + + const output = result.output?.result + if (!output || typeof output !== 'object') { + return { + status: 'no-verdict', + message: 'Condition evaluation returned no verdict', + timedOut: false, + } + } + + const { matchedIndex, threwAtIndex, message } = output as { + matchedIndex?: unknown + threwAtIndex?: unknown + message?: unknown + } + + if (typeof threwAtIndex === 'number' && threwAtIndex >= 0) { + if (threwAtIndex >= expressions.length) { + return { + status: 'no-verdict', + message: 'Condition evaluation reported an unknown branch', + timedOut: false, + } + } + return { + status: 'expression-threw', + index: threwAtIndex, + message: typeof message === 'string' && message ? message : 'Unknown evaluation error', + } + } + + // The script always reports a `matchedIndex`, so anything else is a response + // this handler did not produce. Treating that as "no branch matched" would + // silently route the run down the else path on a garbled reply. + if (typeof matchedIndex !== 'number') { + return { + status: 'no-verdict', + message: 'Condition evaluation returned an unrecognized verdict', + timedOut: false, + } + } + if (matchedIndex < 0) { + return { status: 'no-match' } + } + if (matchedIndex >= expressions.length) { + return { + status: 'no-verdict', + message: 'Condition evaluation matched an unknown branch', + timedOut: false, } + } + + return { status: 'matched', index: matchedIndex } +} + +/** + * Evaluates one expression in its own call. Kept as the fallback for a batch + * that produced no verdict: a syntax error anywhere in the list fails the whole + * script at parse time, while evaluating one at a time only reaches — and so + * only fails on — the branches the run actually takes. + */ +async function evaluateSingleCondition( + ctx: ExecutionContext, + expression: string, + evalContext: Record, + currentNodeId?: string +): Promise { + const code = `const context = ${JSON.stringify(evalContext)};\nreturn ${buildBooleanTest(expression)}` + const result = await runConditionCode(ctx, code, currentNodeId) - return Boolean(result.output?.result) - } catch (evalError: any) { - logger.error('Failed to evaluate condition', { - errorName: evalError?.name, - }) - throw new Error(`Evaluation error in condition: ${evalError.message}`) + if (!result.success) { + throw new Error(result.error ?? 'Condition evaluation failed') } + + return Boolean(result.output?.result) +} + +function conditionError(condition: ConditionEntry, message: string): Error { + return new Error(`Evaluation error in condition "${condition.title}": ${message}`) } /** @@ -175,7 +328,7 @@ export class ConditionBlockHandler implements BlockHandler { return rest } - private parseConditions(input: any): Array<{ id: string; title: string; value: string }> { + private parseConditions(input: any): ConditionEntry[] { try { const conditions = Array.isArray(input) ? input : JSON.parse(input || '[]') return conditions @@ -192,8 +345,8 @@ export class ConditionBlockHandler implements BlockHandler { private buildEvaluationContext( ctx: ExecutionContext, sourceBlockId?: string - ): Record { - let evalContext: Record = {} + ): Record { + let evalContext: Record = {} if (sourceBlockId) { const sourceOutput = ctx.blockStates.get(sourceBlockId)?.output @@ -208,48 +361,108 @@ export class ConditionBlockHandler implements BlockHandler { return evalContext } + /** + * An else branch wins as soon as it is reached, so only the branches ahead of + * it are ever testable — matching the original loop, which returned on the + * first else it walked past rather than assuming else comes last. + */ private async evaluateConditions( - conditions: Array<{ id: string; title: string; value: string }>, + conditions: ConditionEntry[], outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>, - evalContext: Record, + evalContext: Record, ctx: ExecutionContext, currentNodeId?: string ): Promise<{ selectedConnection: { target: string; sourceHandle?: string } | null - selectedCondition: { id: string; title: string; value: string } | null + selectedCondition: ConditionEntry | null }> { - for (const condition of conditions) { - if (isElseConditionTitle(condition.title)) { - const connection = this.findConnectionForCondition(outgoingConnections, condition.id) - return { selectedConnection: connection ?? null, selectedCondition: condition } + const elseIndex = conditions.findIndex((condition) => isElseConditionTitle(condition.title)) + const testable = elseIndex === -1 ? conditions : conditions.slice(0, elseIndex) + const elseCondition = elseIndex === -1 ? null : conditions[elseIndex] + + const matched = await this.findMatchingCondition(testable, evalContext, ctx, currentNodeId) + const selectedCondition = matched ?? elseCondition + + if (!selectedCondition) { + return { selectedConnection: null, selectedCondition: null } + } + + return { + selectedConnection: + this.findConnectionForCondition(outgoingConnections, selectedCondition.id) ?? null, + selectedCondition, + } + } + + private async findMatchingCondition( + conditions: ConditionEntry[], + evalContext: Record, + ctx: ExecutionContext, + currentNodeId?: string + ): Promise { + if (conditions.length === 0) return null + + const expressions = conditions.map((condition) => String(condition.value || '')) + + let evaluation: ConditionEvaluation + try { + evaluation = await evaluateConditionList(ctx, expressions, evalContext, currentNodeId) + } catch (error) { + evaluation = { + status: 'no-verdict', + message: getErrorMessage(error, 'Condition evaluation failed'), + timedOut: false, } + } + + switch (evaluation.status) { + case 'matched': + return conditions[evaluation.index] + case 'no-match': + return null + case 'expression-threw': + logger.error('Failed to evaluate condition', { conditionCount: conditions.length }) + throw conditionError(conditions[evaluation.index], evaluation.message) + case 'no-verdict': + // Retrying one branch at a time is what recovers a batch the sandbox + // could not parse. It is the wrong move for a stalled transport, which + // would re-run every branch against the same stall, or for a cancelled + // run, where every retry aborts on arrival — both surface the batch + // failure as it stands. The whole list was one call, so no single + // branch owns that failure; name the first, where evaluation started. + if (evaluation.timedOut || ctx.abortSignal?.aborted) { + logger.error('Failed to evaluate conditions', { conditionCount: conditions.length }) + throw conditionError(conditions[0], evaluation.message) + } + logger.warn('Batched condition evaluation produced no verdict, retrying one at a time', { + conditionCount: conditions.length, + }) + return this.findMatchingConditionIndividually(conditions, evalContext, ctx, currentNodeId) + } + } - const conditionValueString = String(condition.value || '') + private async findMatchingConditionIndividually( + conditions: ConditionEntry[], + evalContext: Record, + ctx: ExecutionContext, + currentNodeId?: string + ): Promise { + for (const condition of conditions) { try { - const conditionMet = await evaluateConditionExpression( + const conditionMet = await evaluateSingleCondition( ctx, - conditionValueString, + String(condition.value || ''), evalContext, currentNodeId ) - - if (conditionMet) { - const connection = this.findConnectionForCondition(outgoingConnections, condition.id) - if (connection) { - return { selectedConnection: connection, selectedCondition: condition } - } - return { selectedConnection: null, selectedCondition: condition } - } - } catch (error: any) { - logger.error('Failed to evaluate condition', { - errorName: error?.name, - hasTitle: typeof condition.title === 'string' && condition.title.length > 0, - }) - throw new Error(`Evaluation error in condition "${condition.title}": ${error.message}`) + if (conditionMet) return condition + } catch (error) { + logger.error('Failed to evaluate condition', { errorName: toError(error).name }) + throw conditionError(condition, getErrorMessage(error, 'Condition evaluation failed')) } } - return { selectedConnection: null, selectedCondition: null } + return null } private findConnectionForCondition( diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index cd595b345b9..a79f4a8297c 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -26,9 +26,9 @@ import { getDesktopBridge } from '@/lib/desktop' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { requireWorkspaceCredentialListResponse } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { SETTINGS_RETURN_URL_KEY } from '@/hooks/use-settings-navigation' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' -const SETTINGS_RETURN_URL_KEY = 'settings-return-url' const CONTEXT_MAX_AGE_MS = 15 * 60 * 1000 export interface OAuthResultMessage { diff --git a/apps/sim/hooks/use-search-filter-value.test.tsx b/apps/sim/hooks/use-search-filter-value.test.tsx new file mode 100644 index 00000000000..8ec813e5d5d --- /dev/null +++ b/apps/sim/hooks/use-search-filter-value.test.tsx @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' + +const DELAY_MS = 200 +const mountedRoots: Root[] = [] + +/** Drives the hook the way a search box does: re-render with each new input value. */ +function renderSearchFilterValue(initial: string) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + let result = '' + + function Probe({ value }: { value: string }) { + result = useSearchFilterValue(value, DELAY_MS) + return null + } + + act(() => root.render()) + + return { + get current() { + return result + }, + type(value: string) { + act(() => root.render()) + }, + settle() { + act(() => { + vi.advanceTimersByTime(DELAY_MS) + }) + }, + wait(ms: number) { + act(() => { + vi.advanceTimersByTime(ms) + }) + }, + } +} + +describe('useSearchFilterValue', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() + }) + + it('filters on a deep-linked term from the first render', () => { + expect(renderSearchFilterValue('report').current).toBe('report') + }) + + it('starts empty and applies a typed term only once it settles', () => { + const probe = renderSearchFilterValue('') + expect(probe.current).toBe('') + probe.type('rep') + expect(probe.current).toBe('') + probe.settle() + expect(probe.current).toBe('rep') + }) + + it('drops the term the instant it is cleared, without waiting out the window', () => { + const probe = renderSearchFilterValue('report') + probe.type('') + expect(probe.current).toBe('') + }) + + /** + * The regression this hook exists to prevent, and the one masking alone did not: opening a + * folder clears the term, and typing again inside the same debounce window must not resurrect + * the term from before the clear — the list would search the whole workspace for something + * the user had already abandoned. + */ + it('never resurrects the pre-clear term when the user types again straight away', () => { + const probe = renderSearchFilterValue('report') + probe.type('') + probe.wait(DELAY_MS / 4) + probe.type('b') + expect(probe.current).toBe('') + probe.wait(DELAY_MS / 4) + expect(probe.current).toBe('') + probe.settle() + expect(probe.current).toBe('b') + }) + + it('keeps showing the previous term while a longer one is still being typed', () => { + const probe = renderSearchFilterValue('') + probe.type('re') + probe.settle() + expect(probe.current).toBe('re') + probe.type('rep') + expect(probe.current).toBe('re') + probe.settle() + expect(probe.current).toBe('rep') + }) + + it('treats a whitespace-only term as cleared', () => { + const probe = renderSearchFilterValue('report') + probe.type(' ') + expect(probe.current).toBe('') + probe.settle() + expect(probe.current).toBe('') + }) +}) diff --git a/apps/sim/hooks/use-search-filter-value.ts b/apps/sim/hooks/use-search-filter-value.ts new file mode 100644 index 00000000000..37eedcd6977 --- /dev/null +++ b/apps/sim/hooks/use-search-filter-value.ts @@ -0,0 +1,45 @@ +'use client' + +import { useEffect, useState } from 'react' + +/** + * The search term a list should actually filter by: debounced while the user types, but + * discarded the moment the term is cleared. + * + * A plain trailing debounce keeps filtering by the old term for a full window after a clear. + * Typing can afford that — nobody expects results before they stop typing — but clearing + * cannot, because clearing is how a list stops being a search. On a foldered list the term is + * cleared as part of opening a folder, so a trailing edge leaves the previous whole-workspace + * results on screen after the breadcrumb has already changed, and the rows snap a fifth of a + * second later. The click reads as having done nothing, twice. + * + * Masking the settled term while the input is empty is not enough, because the mask lifts as + * soon as the user types again: between that keystroke and the end of its own window the hook + * would hand back the term from *before* the clear, and the list would search the whole + * workspace for something the user had already abandoned. So a clear resets the settled term + * rather than hiding it, and the next term applies only once it settles on its own. + */ +export function useSearchFilterValue(value: string, delayMs: number): string { + const isSearching = value.trim().length > 0 + /** Seeded from the first value so a deep-linked `?search=` filters on the first render. */ + const [settled, setSettled] = useState(() => (value.trim() ? value : '')) + const [wasSearching, setWasSearching] = useState(isSearching) + + /** + * Adjusted during render rather than in an effect so the reset is already visible to the + * render that follows the clear — an effect would land a frame later, which is the same + * stale window in a smaller costume. See `.claude/rules/sim-hooks.md`, "State shape". + */ + if (wasSearching !== isSearching) { + setWasSearching(isSearching) + if (!isSearching) setSettled('') + } + + useEffect(() => { + if (!isSearching) return + const timer = setTimeout(() => setSettled(value), delayMs) + return () => clearTimeout(timer) + }, [value, isSearching, delayMs]) + + return isSearching ? settled : '' +} diff --git a/apps/sim/hooks/use-settings-navigation.test.ts b/apps/sim/hooks/use-settings-navigation.test.ts index e2839fe0a22..2d10a0a98a3 100644 --- a/apps/sim/hooks/use-settings-navigation.test.ts +++ b/apps/sim/hooks/use-settings-navigation.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: vi.fn(() => ({ data: null, isPending: false })), })) -import { resolveSettingsHref } from '@/hooks/use-settings-navigation' +import { resolveSettingsHref, resolveSettingsReturnUrl } from '@/hooks/use-settings-navigation' const HOST_CONTEXT: WorkspaceHostContext = { workspace: { @@ -107,3 +107,43 @@ describe('resolveSettingsHref unified settings navigation', () => { ).toBe('/workspace/workspace-b/settings/billing') }) }) + +describe('resolveSettingsReturnUrl', () => { + const fallback = '/workspace/workspace-b' + + it('returns the stored url when it belongs to the current workspace', () => { + expect( + resolveSettingsReturnUrl({ + storedUrl: '/workspace/workspace-b/w/workflow-a', + workspaceId: 'workspace-b', + fallback, + }) + ).toBe('/workspace/workspace-b/w/workflow-a') + }) + + it('discards a stored url captured in a workspace the user has since left', () => { + expect( + resolveSettingsReturnUrl({ + storedUrl: '/workspace/workspace-a/w/workflow-a', + workspaceId: 'workspace-b', + fallback, + }) + ).toBe(fallback) + }) + + it('keeps workspace-agnostic stored urls', () => { + expect( + resolveSettingsReturnUrl({ + storedUrl: '/account/settings/billing', + workspaceId: 'workspace-b', + fallback, + }) + ).toBe('/account/settings/billing') + }) + + it('falls back when nothing was stored', () => { + expect( + resolveSettingsReturnUrl({ storedUrl: null, workspaceId: 'workspace-b', fallback }) + ).toBe(fallback) + }) +}) diff --git a/apps/sim/hooks/use-settings-navigation.ts b/apps/sim/hooks/use-settings-navigation.ts index 532ffb84bc7..4b2d0635510 100644 --- a/apps/sim/hooks/use-settings-navigation.ts +++ b/apps/sim/hooks/use-settings-navigation.ts @@ -8,7 +8,7 @@ import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -const SETTINGS_RETURN_URL_KEY = 'settings-return-url' +export const SETTINGS_RETURN_URL_KEY = 'settings-return-url' interface SettingsNavigationOptions { section?: SettingsSection @@ -57,6 +57,31 @@ export function resolveSettingsHref({ return query ? `${pathname}?${query}` : pathname } +interface ResolveSettingsReturnUrlParams { + storedUrl: string | null + workspaceId?: string + fallback: string +} + +/** + * Resolves the stored settings return url, discarding it when it points at a + * different workspace than the one currently open. Switching workspaces from + * settings keeps the user on the new workspace, so a return url captured in the + * old one would silently navigate them back out of it. + */ +export function resolveSettingsReturnUrl({ + storedUrl, + workspaceId, + fallback, +}: ResolveSettingsReturnUrlParams): string { + if (!storedUrl) return fallback + const [, root, storedWorkspaceId] = storedUrl.split('/') + if (root === 'workspace' && storedWorkspaceId && storedWorkspaceId !== workspaceId) { + return fallback + } + return storedUrl +} + export function useSettingsNavigation(): UseSettingsNavigationReturn { const router = useRouter() const params = useParams<{ workspaceId?: string }>() @@ -77,15 +102,18 @@ export function useSettingsNavigation(): UseSettingsNavigationReturn { [hostContext, session?.user?.id, workspaceId] ) - const popSettingsReturnUrl = useCallback((fallback: string): string => { - try { - const url = sessionStorage.getItem(SETTINGS_RETURN_URL_KEY) - sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) - return url ?? fallback - } catch { - return fallback - } - }, []) + const popSettingsReturnUrl = useCallback( + (fallback: string): string => { + try { + const storedUrl = sessionStorage.getItem(SETTINGS_RETURN_URL_KEY) + sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) + return resolveSettingsReturnUrl({ storedUrl, workspaceId, fallback }) + } catch { + return fallback + } + }, + [workspaceId] + ) const navigateToSettings = useCallback( (options?: SettingsNavigationOptions) => { diff --git a/apps/sim/lib/api/contracts/v2/__tests__/files-recursive.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/files-recursive.test.ts new file mode 100644 index 00000000000..6eb3dc91a4c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/files-recursive.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { v2ListFilesQuerySchema } from '@/lib/api/contracts/v2/files' + +const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' + +/** Parses what a query string actually delivers: strings, never booleans. */ +function parseRecursive(raw: string | undefined) { + const result = v2ListFilesQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + ...(raw === undefined ? {} : { recursive: raw }), + }) + return result.success ? result.data.recursive : { error: true as const } +} + +describe('v2ListFilesQuerySchema recursive', () => { + it('omits to undefined so the route can pick a default from the search', () => { + expect(parseRecursive(undefined)).toBeUndefined() + }) + + /** + * The bug this guards: `z.coerce.boolean()` is `Boolean(input)` over a query string, so + * every non-empty spelling — `false` included — arrives as `true`, silently inverting the + * one value a caller sends explicitly to turn recursion off. + */ + it('reads "false" as false, not as a non-empty string', () => { + expect(parseRecursive('false')).toBe(false) + expect(parseRecursive('0')).toBe(false) + expect(parseRecursive('no')).toBe(false) + }) + + it('reads the true spellings as true', () => { + expect(parseRecursive('true')).toBe(true) + expect(parseRecursive('1')).toBe(true) + expect(parseRecursive('yes')).toBe(true) + }) + + /** Case-sensitive by design: an unpublished spelling is a 400, never a silent default. */ + it('rejects spellings outside the published vocabulary', () => { + expect(parseRecursive('True')).toEqual({ error: true }) + expect(parseRecursive('TRUE')).toEqual({ error: true }) + expect(parseRecursive('maybe')).toEqual({ error: true }) + expect(parseRecursive('')).toEqual({ error: true }) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 248c4069cc4..d52ec521993 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -132,7 +132,16 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/billing/logs': ['source', 'workspaceId', 'period', 'startDate', 'endDate'], 'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'], - 'GET /api/v2/files': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/files': [ + 'workspaceId', + 'scope', + 'folderPath', + 'search', + 'sortBy', + 'sortOrder', + /** Decides whether `folderPath` covers one folder or its whole subtree. */ + 'recursive', + ], 'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/knowledge/[id]/documents': [ 'workspaceId', diff --git a/apps/sim/lib/api/contracts/v2/error-codes.test.ts b/apps/sim/lib/api/contracts/v2/error-codes.test.ts new file mode 100644 index 00000000000..88e479635fb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/error-codes.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + V2_ERROR_CODE_BY_STATUS, + V2_ERROR_STATUS_BY_CODE, + type V2ErrorCode, +} from '@/lib/api/contracts/v2/error-codes' + +describe('v2 error codes', () => { + /** + * The property the OpenAPI layer depends on: a documented error response derives its + * `error.code` from the status it is declared under, so two codes sharing a status would + * make one of those examples name a code that status never carries. + */ + it('maps each code onto a status no other code claims', () => { + const claimedBy = new Map() + for (const [code, status] of Object.entries(V2_ERROR_STATUS_BY_CODE) as [ + V2ErrorCode, + number, + ][]) { + expect(claimedBy.get(status), `${status} is claimed by more than one code`).toBeUndefined() + claimedBy.set(status, code) + } + expect(claimedBy.size).toBe(Object.keys(V2_ERROR_STATUS_BY_CODE).length) + }) + + it('inverts without losing an entry', () => { + for (const [code, status] of Object.entries(V2_ERROR_STATUS_BY_CODE) as [ + V2ErrorCode, + number, + ][]) { + expect(V2_ERROR_CODE_BY_STATUS[status]).toBe(code) + } + }) + + it('uses statuses in the HTTP error range', () => { + for (const status of Object.values(V2_ERROR_STATUS_BY_CODE)) { + expect(status).toBeGreaterThanOrEqual(400) + expect(status).toBeLessThan(600) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/error-codes.ts b/apps/sim/lib/api/contracts/v2/error-codes.ts new file mode 100644 index 00000000000..38f00240bc5 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/error-codes.ts @@ -0,0 +1,63 @@ +/** + * The closed set of `error.code` values the public v2 API emits, and the HTTP status each + * one is sent with. + * + * Lives in the contract layer rather than beside the response helpers because two consumers + * need it and they must not drift: the runtime (`app/api/v2/lib/response.ts`, which renders + * the envelope) and the published OpenAPI documents (`contracts/v2/openapi/shared.ts`, which + * document one example per status). Documenting a code the runtime cannot emit — or a status + * paired with the wrong code — is how the reference stops describing the API. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'CLIENT_CLOSED_REQUEST' + | 'INTERNAL_ERROR' + | 'SERVICE_UNAVAILABLE' + +/** + * The status each code is sent with. `Record` is the completeness gate: + * adding a code fails to compile until it declares its status. + */ +export const V2_ERROR_STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + CLIENT_CLOSED_REQUEST: 499, + INTERNAL_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} + +/** + * The inverse map. + * + * The pairing is one-to-one, which is what lets a documented error response derive its code + * from the status it is declared under instead of restating it. That property is load-bearing + * for the API reference, so it is asserted in `error-codes.test.ts` rather than trusted — a + * second code claiming a status would silently drop an entry here and make one documented + * example name the wrong code. + * + * This describes how a code chooses its status, not every status the surface can send. A + * route may pass an explicit status alongside a code — `POST /workflows/{id}/execute` answers + * `408` with `BAD_REQUEST` — so a status absent from this map is one no documented error + * response may claim, which is what the OpenAPI layer enforces. + */ +export const V2_ERROR_CODE_BY_STATUS: Partial> = Object.fromEntries( + Object.entries(V2_ERROR_STATUS_BY_CODE).map(([code, status]) => [status, code as V2ErrorCode]) +) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index b422d7792c0..8b0ba62b39b 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -9,7 +9,9 @@ import { import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { + V2_FALSE_VALUES, V2_FOLDER_FILTER_MISS, + V2_TRUE_VALUES, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -306,7 +308,30 @@ export const v2ListFilesQuerySchema = z /** Restrict to one file folder. Omit to list the whole workspace. */ folderPath: v2FolderPathInputSchema .optional() - .describe(`Restrict results to files directly inside this folder. ${V2_FOLDER_FILTER_MISS}`), + .describe( + `Restrict results to files inside this folder — its direct children, or its whole subtree when \`recursive\` is true. ${V2_FOLDER_FILTER_MISS}` + ), + /** + * Descend into subfolders. Meaningful only alongside `folderPath`: with no folder filter + * the listing already spans the workspace. + * + * Defaults to `true` when `search` is set and `false` otherwise, so the two verbs this + * endpoint serves each get the scope they imply — listing a folder shows that folder, + * searching one looks through everything in it. Send it explicitly to force either. + * + * `z.stringbool({ case: 'sensitive' })` rather than `z.coerce.boolean()`, which is + * `Boolean(input)` over a query string and so reads `recursive=false` as `true` — see + * `booleanQueryFlagSchema` in `contracts/primitives.ts`. Matches the sibling `recursive` + * on folder delete: the accepted spellings are closed, published as an enum, and + * case-sensitive, so an unpublished spelling is a `400` rather than a silent default. + */ + recursive: z + .stringbool({ case: 'sensitive' }) + .optional() + .describe( + 'Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.' + ) + .meta({ enum: [...V2_TRUE_VALUES, ...V2_FALSE_VALUES] }), scope: v2FileScopeSchema .default('active') .describe( @@ -325,6 +350,18 @@ export const v2ListFilesQuerySchema = z export type V2ListFilesQuery = z.output +/** + * Resolves the `recursive` default the schema above promises: true alongside a search, false + * otherwise, and whatever the caller sent when they sent one. + * + * Lives beside the `.describe()` that publishes the rule to every SDK and CLI rather than in + * the route that applies it. The promise and the implementation were two modules apart with + * nothing binding them, which is how a documented default drifts from the served one. + */ +export function listsSubfolders(query: { recursive?: boolean; search?: string }): boolean { + return query.recursive ?? query.search !== undefined +} + /** Download/delete both target a single file within a workspace-scoped query. */ export const v2FileWorkspaceQuerySchema = z .object({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 32074b7ed7e..26182ca344e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -24,7 +24,6 @@ import { } from '@/lib/api/contracts/v2/files' import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, @@ -39,6 +38,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -902,6 +902,8 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ ...V2_COMMON_HEADERS, }, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'File already exists' }, + }), routes, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index fa8e2e06453..3ce43fea60e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -24,7 +24,6 @@ import { } from '@/lib/api/contracts/v2/knowledge' import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, @@ -37,6 +36,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -781,6 +781,8 @@ export const knowledgeOpenApiDocument = defineOpenApiDocument({ securitySchemes: V2_API_KEY_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'Upload has already been completed' }, + }), routes, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 3c0a0cc3d3f..50ebb34a817 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -22,7 +22,6 @@ import { } from '@/lib/api/contracts/v2/mcp-servers' import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FULL_SET_LIST, HEAD_MIRRORS_GET, @@ -34,6 +33,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -1158,6 +1158,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ securitySchemes: V2_API_KEY_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'API key name already exists' }, + }), routes, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 74804f1053a..0a774a71c5b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { V2_ERROR_CODE_BY_STATUS } from '@/lib/api/contracts/v2/error-codes' import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared' import type { OpenApiErrorResponse, @@ -7,6 +8,47 @@ import type { OpenApiSecurityScheme, } from '@/lib/api/openapi/types' +interface ErrorResponseOptions { + /** + * The `message` a caller actually receives for this status. Every one below is a literal + * the runtime emits — the generic path for that status, not a domain's phrasing of it — + * so the reference can be read as the response rather than as an illustration. + */ + message: string + /** `error.details`, for the statuses that populate it. Omitted where none is sent. */ + details?: unknown + headers?: readonly string[] +} + +/** + * One documented error response, with `error.code` derived from the status rather than + * restated beside it. + * + * Deriving is what keeps the reference honest: a hand-written pair can drift into naming a + * code the status never carries, and that mistake reads as authoritative. The v2 codes map + * one-to-one onto statuses ({@link V2_ERROR_CODE_BY_STATUS} throws if that ever stops being + * true), so the status is + * enough to determine the code. + */ +function errorResponse( + status: number, + description: string, + { message, details, headers }: ErrorResponseOptions +): OpenApiErrorResponse { + const code = V2_ERROR_CODE_BY_STATUS[status] + if (!code) { + throw new Error( + `No v2 error code is sent with status ${status}; documenting it would publish a response the API cannot produce.` + ) + } + return { + status, + description, + ...(headers ? { headers } : {}), + example: { error: { code, message, ...(details === undefined ? {} : { details }) } }, + } +} + export const RATE_LIMIT_HEADERS = [ 'X-RateLimit-Limit', 'X-RateLimit-Remaining', @@ -46,40 +88,79 @@ const FORBIDDEN_DESCRIPTION = 'The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.' export const ERROR_RESPONSES = { - BadRequest: { - status: 400, - description: - 'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.', - }, - Unauthorized: { status: 401, description: 'The API key is missing or invalid.' }, - UsageLimitExceeded: { - status: 402, - description: 'The workspace has exceeded its usage or billing limits.', - }, - Forbidden: { status: 403, description: FORBIDDEN_DESCRIPTION }, - NotFound: { status: 404, description: 'The requested resource was not found.' }, - Conflict: { status: 409, description: 'The request conflicts with current resource state.' }, - RunIdConflict: { - status: 409, - description: - 'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.', - headers: ['X-Run-Id'], - }, - PayloadTooLarge: { - status: 413, - description: - 'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.', - }, - UnsupportedMediaType: { - status: 415, - description: 'The request uses an unsupported media type.', - }, - Locked: { status: 423, description: 'The resource is locked and cannot be modified.' }, - RateLimited: { - status: 429, - description: 'The caller exceeded the request rate limit.', + BadRequest: errorResponse( + 400, + 'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.', + { + /** + * The fallback `getValidationErrorMessage` uses when an issue carries no message of its + * own; a real failure usually names the offending field instead (`limit must be at + * least 1`). The generic form is documented because this response is shared by every + * operation, and `details` is omitted because only the validation path populates it — + * the cursor and malformed-JSON 400s send none. + */ + message: 'Invalid request', + } + ), + Unauthorized: errorResponse(401, 'The API key is missing or invalid.', { + message: 'API key required', + }), + UsageLimitExceeded: errorResponse( + 402, + 'The workspace has exceeded its usage or billing limits.', + { message: 'Usage limit exceeded. Please upgrade your plan to continue.' } + ), + Forbidden: errorResponse(403, FORBIDDEN_DESCRIPTION, { + message: 'Insufficient workspace permissions', + /** The description tells callers to branch on this, so the example has to show it. */ + details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' }, + }), + NotFound: errorResponse(404, 'The requested resource was not found.', { + /** + * The surface-wide literal. A resource route answers with its own noun instead — `Workflow + * not found`, `Table not found` — which this response cannot name because every domain + * shares it. + */ + message: 'Not found', + }), + Conflict: errorResponse(409, 'The request conflicts with current resource state.', { + /** + * The workflows phrasing, which is the default because that document does not override + * it. Nothing in the response layer supplies a 409 message, so every other document + * carrying this status names its own through {@link withErrorExamples}. + */ + message: 'Webhook path already in use', + }), + RunIdConflict: errorResponse( + 409, + 'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.', + { + message: 'Run ID has already been used', + details: { code: 'RUN_ID_CONFLICT', runId: '0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01' }, + headers: ['X-Run-Id'], + } + ), + PayloadTooLarge: errorResponse( + 413, + 'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.', + { message: 'Request body is too large' } + ), + UnsupportedMediaType: errorResponse(415, 'The request uses an unsupported media type.', { + message: 'Request body must be sent as application/json', + }), + Locked: errorResponse(423, 'The resource is locked and cannot be modified.', { + /** + * Also domain-supplied. Workflows and tables are the only documents that carry a `423`, + * and tables names its own four lock kinds through {@link withErrorExamples}. + */ + message: 'Workflow is locked', + }), + RateLimited: errorResponse(429, 'The caller exceeded the request rate limit.', { + message: 'API rate limit exceeded', + /** Mirrors `Retry-After`, which the description already sends callers to. */ + details: { retryAfter: '2026-01-01T00:00:30.000Z' }, headers: ['Retry-After'], - }, + }), /** * Published on exactly one operation, and deliberately not on the rest. * @@ -98,22 +179,66 @@ export const ERROR_RESPONSES = { * leaves nothing behind to reconcile. Publish a 499 on a new operation only * when the same is true of it. */ - ClientClosedRequest: { - status: 499, - description: - 'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', - }, - InternalError: { status: 500, description: 'An unexpected server error occurred.' }, - ServiceUnavailable: { - status: 503, - description: - 'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.', - headers: ['Retry-After'], - }, + ClientClosedRequest: errorResponse( + 499, + 'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', + { + message: 'Client cancelled request', + /** The run this abort may have left running — the reason the status is published. */ + details: { runId: '0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01' }, + } + ), + InternalError: errorResponse(500, 'An unexpected server error occurred.', { + /** + * Hardcoded on every path. `v2ErrorForOrchestration` replaces a domain's `internal` + * message with this literal, so a 500 never leaks one — the reference showing a + * descriptive message here would suggest callers can parse something they never get. + */ + message: 'Internal server error', + }), + ServiceUnavailable: errorResponse( + 503, + 'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.', + { message: 'Service temporarily unavailable', headers: ['Retry-After'] } + ), } as const satisfies Readonly> export type ErrorResponseId = keyof typeof ERROR_RESPONSES +/** + * {@link ERROR_RESPONSES} with a document's own body for the statuses whose message is the + * domain's rather than the surface's. + * + * Most statuses read the same everywhere — a `401` is `API key required` whatever you were + * asking for. `409` and `423` are not: nothing in the response layer supplies them, so the + * only real strings are each domain's, and one shared example necessarily shows four of the + * seven documents a message they never send. Tables answering `Workflow is locked` is the + * same class of wrongness as every status answering `BAD_REQUEST`, just smaller. + * + * Status, description, and headers are kept — a document may restate what its errors *say*, + * never what they *mean* — and the code is re-derived, so an override cannot introduce the + * mismatch this whole mechanism exists to prevent. + */ +export function withErrorExamples( + overrides: Partial> +): Record { + const entries = Object.entries(ERROR_RESPONSES) as [ErrorResponseId, OpenApiErrorResponse][] + return Object.fromEntries( + entries.map(([id, response]) => { + const override = overrides[id] + if (!override) return [id, response] + return [ + id, + errorResponse(response.status, response.description, { + message: override.message, + details: override.details, + headers: response.headers, + }), + ] + }) + ) as Record +} + /** * The three sets below are the base shapes every workspace-scoped resource * operation in the v2 API actually emits, so they live here once rather than diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index d83ca48fbaa..dfff91921c5 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -1,6 +1,5 @@ import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, @@ -13,6 +12,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_ERRORS, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -1603,6 +1603,13 @@ export const tablesOpenApiDocument = defineOpenApiDocument({ securitySchemes: V2_API_KEY_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'A table named "Orders" already exists in this workspace' }, + Locked: { + message: 'This table is insert-locked: new rows cannot be added.', + /** Names which of the four locks refused the write, so a caller can say which. */ + details: { lock: 'insert' }, + }, + }), routes, }) diff --git a/apps/sim/lib/api/openapi/types.ts b/apps/sim/lib/api/openapi/types.ts index 49873c7fcf8..59225b968ae 100644 --- a/apps/sim/lib/api/openapi/types.ts +++ b/apps/sim/lib/api/openapi/types.ts @@ -50,6 +50,19 @@ export interface OpenApiErrorResponse { status: number description: string headers?: readonly string[] + /** + * The body this status is documented with, validated against the document's `errorSchema` + * at generation time. + * + * Required, because the alternative is what every status rendered before it existed: the + * error schema's single shared example, so the reference answered `400 BAD_REQUEST` under + * the `401`, `404`, and `500` tabs alike. A reference that confidently shows the wrong + * body is worse than one that shows none. + * + * The document's own layer assembles it — only that layer knows how its codes pair with + * statuses — and this generator checks it parses. + */ + example: unknown } export interface OpenApiSuccessMetadata { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 315a9bb4349..8d9d784bd32 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -390,8 +390,11 @@ export const env = createEnv({ KB_CONFIG_RETRY_FACTOR: z.number().optional().default(2), // Retry backoff factor KB_CONFIG_MIN_TIMEOUT: z.number().optional().default(1000), // Min timeout in ms KB_CONFIG_MAX_TIMEOUT: z.number().optional().default(10000), // Max timeout in ms - KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(50), // Concurrent embedding API calls + KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(20), // Concurrent document-processing task runs (Trigger.dev queue depth) + KB_CONFIG_EMBEDDING_CONCURRENCY: z.number().optional().default(8), // Concurrent embedding API requests within one embed call + KB_CONFIG_DOCUMENT_CONCURRENCY: z.number().optional().default(4), // Concurrent documents in the in-process (non-Trigger) path KB_CONFIG_BATCH_SIZE: z.number().optional().default(2000), // Chunks to process per embedding batch + KB_CONFIG_DOCUMENT_BATCH_SIZE: z.number().optional().default(10), // Documents per batch in the in-process (non-Trigger) path KB_CONFIG_DELAY_BETWEEN_BATCHES: z.number().optional().default(0), // Delay between batches in ms (0 for max speed) KB_CONFIG_DELAY_BETWEEN_DOCUMENTS: z.number().optional().default(50), // Delay between documents in ms KB_CONFIG_CHUNK_CONCURRENCY: z.number().optional().default(10), // Concurrent PDF chunk OCR processing @@ -727,6 +730,13 @@ export { getEnv } * `z.number()` arrive as raw strings when sourced from `process.env` or Helm. * Use this helper anywhere a numeric env override is consumed to normalize the * type at the boundary instead of relying on JS implicit coercion. + * + * Skipping validation also means the schema never runs, so a `.default(...)` in + * the declaration above never executes: **the fallback passed here is the real + * default**, and the declared one is documentation. Keep the two in agreement — + * a variable read in more than one place with a different fallback each time has + * no single default at all, which is how one knob came to set both the + * document-processing queue depth and the embedding request fan-out. */ export function envNumber( value: number | string | undefined | null, diff --git a/apps/sim/lib/embeddings/client.test.ts b/apps/sim/lib/embeddings/client.test.ts index d2931d9fe26..0f2d4e66eaa 100644 --- a/apps/sim/lib/embeddings/client.test.ts +++ b/apps/sim/lib/embeddings/client.test.ts @@ -4,6 +4,7 @@ import { resetEnvMock, setEnv } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + EMBEDDING_MAX_RETRIES, EmbeddingAPIError, embed, embedKnowledgeForDeployment, @@ -28,11 +29,13 @@ vi.mock('@/lib/api-key/byok', () => ({ const originalFetch = global.fetch -function jsonResponse(body: unknown, status = 200): Response { +function jsonResponse(body: unknown, status = 200, responseHeaders?: HeadersInit): Response { return { ok: status >= 200 && status < 300, status, statusText: String(status), + // A real Response always carries these; the failure path reads them for rate-limit signals. + headers: new Headers(responseHeaders), json: async () => body, text: async () => JSON.stringify(body), } as Response @@ -677,11 +680,12 @@ describe('knowledge embedding transport fallback', () => { await vi.runAllTimersAsync() const result = await pending - expect(fetchMock).toHaveBeenCalledTimes(5) - expect(fetchMock.mock.calls.slice(0, 4).every(([url]) => url.includes('api.openai.com'))).toBe( - true - ) - expect(fetchMock.mock.calls[4][0]).toBe('https://openrouter.ai/api/v1/embeddings') + const attempts = EMBEDDING_MAX_RETRIES + 1 + expect(fetchMock).toHaveBeenCalledTimes(attempts + 1) + expect( + fetchMock.mock.calls.slice(0, attempts).every(([url]) => url.includes('api.openai.com')) + ).toBe(true) + expect(fetchMock.mock.calls[attempts][0]).toBe('https://openrouter.ai/api/v1/embeddings') expect(projectInputs).toHaveBeenCalledOnce() expect(result.embeddings).toEqual([[7, 8]]) }) @@ -713,13 +717,111 @@ describe('knowledge embedding transport fallback', () => { .filter(([url]) => url === 'https://openrouter.ai/api/v1/embeddings') .flatMap(([, init]) => JSON.parse((init as RequestInit).body as string).input as string[]) expect(openRouterInputs).toEqual([secondInput]) - expect(fetchMock).toHaveBeenCalledTimes(6) + // The succeeding batch, every attempt on the failing one, then its fallback. + expect(fetchMock).toHaveBeenCalledTimes(1 + (EMBEDDING_MAX_RETRIES + 1) + 1) expect(result.embeddings).toEqual([[1], [2]]) expect(result.totalTokens).toBe(6) expect(result.billableTokens).toBe(3) expect(result.isBYOK).toBe(false) }) + /** + * The retry loop replaces its own backoff with a provider-stated wait, but only + * if the wait reaches it. Nothing downstream of the transport could see the + * response headers, so a rate-limited embedding request retried blind. + */ + it('carries the provider-stated retry wait onto the thrown error', async () => { + vi.useFakeTimers() + setEnv({ OPENAI_API_KEY: 'openai-test' }) + fetchMock.mockResolvedValue({ + ok: false, + status: 429, + statusText: '429', + headers: new Headers({ 'retry-after': '42' }), + json: async () => ({ error: 'rate limited' }), + text: async () => 'rate limited', + } as Response) + + const pending = embed(['hello'], { ...options, apiKey: 'openai-test' }).catch((e) => e) + await vi.runAllTimersAsync() + const error = await pending + + expect(error).toBeInstanceOf(EmbeddingAPIError) + expect(error.status).toBe(429) + expect(error.retryAfterMs).toBe(42_000) + }) + + /** + * A stated wait past the ceiling cannot be honoured, so retrying only clamps + * every attempt below the reopen time and spends the budget for nothing. The + * error still classifies as transient, so the fallback chain takes over at + * once rather than after the retries burn down. + */ + it('does not retry a wait it cannot honour, falling back immediately', async () => { + vi.useFakeTimers() + setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' }) + const fetchMock = vi.fn().mockImplementation(async (url: string) => + url === 'https://api.openai.com/v1/embeddings' + ? ({ + ok: false, + status: 429, + statusText: '429', + // Six minutes, far past EMBEDDING_MAX_RETRY_DELAY_MS. + headers: new Headers({ + 'x-ratelimit-remaining-tokens': '0', + 'x-ratelimit-reset-tokens': '6m0s', + }), + json: async () => ({ error: 'rate limited' }), + text: async () => 'rate limited', + } as Response) + : jsonResponse(openAIBody([[9, 9]], 2)) + ) + vi.stubGlobal('fetch', fetchMock) + + const pending = embedKnowledgeForDeployment(['hello'], options, false) + await vi.runAllTimersAsync() + const result = await pending + + const openAICalls = fetchMock.mock.calls.filter(([url]) => url.includes('api.openai.com')) + expect(openAICalls).toHaveLength(1) + expect(result.embeddings).toEqual([[9, 9]]) + }) + + /** + * A window shorter than the whole budget is still reachable: the individual + * waits are clamped below it but they accumulate, so a later attempt lands + * after it reopens. Refusing these would strand a single-provider caller that + * had only to wait a little longer than one clamped delay. + */ + it('keeps retrying a wait the budget can outlast, and recovers', async () => { + vi.useFakeTimers() + setEnv({ OPENAI_API_KEY: 'openai-test' }) + let call = 0 + const fetchMock = vi.fn().mockImplementation(async () => { + call++ + if (call === 1) { + return { + ok: false, + status: 429, + statusText: '429', + // Above the per-attempt ceiling, well inside the total budget. + headers: new Headers({ 'retry-after': '35' }), + json: async () => ({ error: 'rate limited' }), + text: async () => 'rate limited', + } as Response + } + return jsonResponse(openAIBody([[4, 4]], 2)) + }) + vi.stubGlobal('fetch', fetchMock) + + const pending = embed(['hello'], { ...options, apiKey: 'openai-test' }) + await vi.runAllTimersAsync() + const result = await pending + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.embeddings).toEqual([[4, 4]]) + }) + it('classifies only transient embedding failures for failover', () => { expect(isTransientEmbeddingError(new EmbeddingAPIError('unavailable', 503))).toBe(true) expect(isTransientEmbeddingError(new EmbeddingAPIError('rate limited', 429))).toBe(true) diff --git a/apps/sim/lib/embeddings/client.ts b/apps/sim/lib/embeddings/client.ts index 912574c77cc..3383de1eaf4 100644 --- a/apps/sim/lib/embeddings/client.ts +++ b/apps/sim/lib/embeddings/client.ts @@ -20,6 +20,7 @@ import { import { resolveProviderKey } from '@/lib/embeddings/keys' import { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models' import { getAdapterFactory } from '@/lib/embeddings/providers' +import { resolveEmbeddingRetryDelayMs } from '@/lib/embeddings/rate-limit' import type { EmbeddingProviderAdapter, EmbeddingTaskType, @@ -27,12 +28,26 @@ import type { EmbedResult, OpenRouterEmbedOptions, } from '@/lib/embeddings/types' -import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' +import { + attachRetryHeaders, + isRetryableError, + retryWithExponentialBackoff, +} from '@/lib/knowledge/documents/utils' import { batchByTokenLimit, estimateTokenCount, truncateToTokenLimit } from '@/lib/tokenization' const logger = createLogger('EmbeddingClient') -const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 50) +/** + * Embedding requests issued concurrently within a single embed call. + * + * A provider's rate limit is per API key, so this multiplies with however many + * documents are being processed at once: the document-processing queue admits + * {@link env.KB_CONFIG_CONCURRENCY_LIMIT} task runs, each reaching here. It was + * previously read from that same variable, so one knob set both factors and the + * product reached four figures of in-flight requests against one key — enough to + * hold a provider at its limit indefinitely, which no retry policy can absorb. + */ +const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_EMBEDDING_CONCURRENCY, 8) const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000 /** @@ -47,9 +62,29 @@ const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000 */ const BATCH_TOKEN_TARGET = 8192 +/** Retries after the initial attempt, per embedding request. */ +export const EMBEDDING_MAX_RETRIES = 5 + +/** Ceiling on a single wait between embedding attempts, including a provider-stated one. */ +export const EMBEDDING_MAX_RETRY_DELAY_MS = 30_000 + +/** + * Longest a request can stay in the retry loop: every attempt waits at most the + * ceiling, so the budget is what the attempts span in total. A provider window + * that reopens inside this is still reachable even though each individual wait + * is clamped below it. + */ +const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_RETRY_DELAY_MS + export class EmbeddingAPIError extends Error { public status: number + /** + * Wait the provider asked for, read from the rejected response. Consumed by + * {@link retryWithExponentialBackoff}, which prefers it over its own backoff. + */ + public retryAfterMs?: number + constructor(message: string, status: number) { super(message) this.name = 'EmbeddingAPIError' @@ -57,6 +92,30 @@ export class EmbeddingAPIError extends Error { } } +/** + * True when the provider's stated wait outlasts the entire retry budget. + * + * The comparison is against {@link EMBEDDING_RETRY_BUDGET_MS} rather than the + * per-attempt ceiling, because a window longer than one wait is still reachable: + * the attempts are clamped individually but accumulate, so a 35s window reopens + * before the second one lands. Only a window outlasting every attempt is + * genuinely unreachable, and retrying into it spends the budget waiting on + * something that cannot happen. + * + * The error stays transient — it just is not worth retrying here — so refusing + * the retry surfaces it immediately and the fallback chain, which classifies + * separately via `shouldFallback`, reaches the next provider at once. Where no + * fallback is configured the request fails either way; this only decides whether + * it fails now or after the budget burns down for nothing. + */ +function statedWaitOutlastsBudget(error: unknown): boolean { + return ( + error instanceof EmbeddingAPIError && + error.retryAfterMs !== undefined && + error.retryAfterMs > EMBEDDING_RETRY_BUDGET_MS + ) +} + export function isTransientEmbeddingError(error: unknown): boolean { if (error instanceof EmbeddingAPIError) { return error.status === 429 || error.status >= 500 @@ -188,10 +247,27 @@ async function callEmbeddingAPI( if (!response.ok) { const errorText = await response.text() - throw new EmbeddingAPIError( + const error = new EmbeddingAPIError( `Embedding API failed: ${response.status} ${response.statusText} - ${errorText}`, response.status ) + + /** + * Carry the provider's own answer to "when may I retry" onto the error, + * the way `fetchWithRetry` does for connectors. Without it the retry + * loop had nothing but blind exponential backoff and would exhaust every + * attempt inside a rate-limit window that had not yet reopened. + * + * The headers travel non-enumerably so the retry condition can re-read + * them without the bag reaching a log line. + */ + attachRetryHeaders(error, response.headers) + const waitMs = resolveEmbeddingRetryDelayMs(response.headers) + if (waitMs !== null) { + error.retryAfterMs = waitMs + } + + throw error } const json = await response.json() @@ -208,10 +284,22 @@ async function callEmbeddingAPI( return { embeddings, totalTokens } }, { - maxRetries: 3, + /** + * Sized against a rate-limit window rather than a transient blip. The + * provider states its reset in tens of seconds, and the loop clamps that + * stated wait to `maxDelayMs` — at the previous 10s ceiling every attempt + * fired before the window reopened, so the budget was spent without one + * retry landing in the reopened window. + * + * Bounded so a fully saturated provider cannot outlive the task: five + * attempts at the ceiling is well inside `KB_CONFIG_MAX_DURATION`, and + * batches wait concurrently rather than one after another. + */ + maxRetries: EMBEDDING_MAX_RETRIES, initialDelayMs: 1000, - maxDelayMs: 10000, - retryCondition: isTransientEmbeddingError, + maxDelayMs: EMBEDDING_MAX_RETRY_DELAY_MS, + retryCondition: (error) => + isTransientEmbeddingError(error) && !statedWaitOutlastsBudget(error), } ) } diff --git a/apps/sim/lib/embeddings/rate-limit.test.ts b/apps/sim/lib/embeddings/rate-limit.test.ts new file mode 100644 index 00000000000..a2d73c1714a --- /dev/null +++ b/apps/sim/lib/embeddings/rate-limit.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { parseGoDurationMs, resolveEmbeddingRetryDelayMs } from '@/lib/embeddings/rate-limit' + +function headers(values: Record): { get(name: string): string | null } { + return { get: (name: string) => values[name] ?? null } +} + +describe('parseGoDurationMs', () => { + it('reads the shapes OpenAI documents for its reset headers', () => { + expect(parseGoDurationMs('1s')).toBe(1000) + expect(parseGoDurationMs('6m0s')).toBe(360_000) + expect(parseGoDurationMs('23h47m36.648s')).toBeCloseTo(85_656_648, 0) + }) + + /** + * `m` and `ms` share a prefix, so a parser that matched the shorter unit first + * would read a twelve-millisecond wait as a twelve-minute one — a 60,000x + * overstatement that would stall ingestion on a healthy provider. + */ + it('does not confuse milliseconds with minutes', () => { + expect(parseGoDurationMs('12ms')).toBe(12) + expect(parseGoDurationMs('12m')).toBe(720_000) + }) + + it('refuses a value the format does not fully describe', () => { + expect(parseGoDurationMs('')).toBeNull() + expect(parseGoDurationMs(' ')).toBeNull() + expect(parseGoDurationMs('soon')).toBeNull() + expect(parseGoDurationMs('60')).toBeNull() + expect(parseGoDurationMs('1s later')).toBeNull() + expect(parseGoDurationMs('1y')).toBeNull() + }) +}) + +describe('resolveEmbeddingRetryDelayMs', () => { + it('prefers Retry-After, which names the wait directly', () => { + expect( + resolveEmbeddingRetryDelayMs( + headers({ 'retry-after': '20', 'x-ratelimit-reset-tokens': '6m0s' }) + ) + ).toBe(20_000) + }) + + /** + * The cap in `parseRetryAfter` defaults to 30s. The retry loop owns the clamp + * against its own ceiling, so a longer stated wait must arrive intact rather + * than being silently truncated on the way in. + */ + it('passes a long Retry-After through uncapped', () => { + expect(resolveEmbeddingRetryDelayMs(headers({ 'retry-after': '120' }))).toBe(120_000) + }) + + it('falls back to the reset of the dimension that is actually exhausted', () => { + expect( + resolveEmbeddingRetryDelayMs( + headers({ + 'x-ratelimit-remaining-tokens': '0', + 'x-ratelimit-reset-tokens': '45s', + 'x-ratelimit-remaining-requests': '4999', + 'x-ratelimit-reset-requests': '6m0s', + }) + ) + ).toBe(45_000) + }) + + it('waits out the longer window when both dimensions are exhausted', () => { + expect( + resolveEmbeddingRetryDelayMs( + headers({ + 'x-ratelimit-remaining-tokens': '0', + 'x-ratelimit-reset-tokens': '45s', + 'x-ratelimit-remaining-requests': '0', + 'x-ratelimit-reset-requests': '6m0s', + }) + ) + ).toBe(360_000) + }) + + /** + * Providers stamp these headers on every response. A reset read while quota + * remains says when the window rolls over, not when this request may be + * retried — using it would pin an unrelated failure to a flat wait. + */ + it('says nothing when no dimension is exhausted', () => { + expect( + resolveEmbeddingRetryDelayMs( + headers({ + 'x-ratelimit-remaining-tokens': '150000', + 'x-ratelimit-reset-tokens': '6m0s', + }) + ) + ).toBeNull() + }) + + it('says nothing when the response carries no rate-limit headers', () => { + expect(resolveEmbeddingRetryDelayMs(headers({}))).toBeNull() + }) +}) diff --git a/apps/sim/lib/embeddings/rate-limit.ts b/apps/sim/lib/embeddings/rate-limit.ts new file mode 100644 index 00000000000..da128b7a094 --- /dev/null +++ b/apps/sim/lib/embeddings/rate-limit.ts @@ -0,0 +1,92 @@ +import { parseRetryAfter } from '@sim/utils/retry' + +/** + * Reads the wait an embedding provider states when it rejects a request for + * rate limiting, in milliseconds, or `null` when the response says nothing + * usable and the caller should fall back to its own backoff. + * + * Kept out of the shared connector helper in `@/lib/knowledge/documents/utils` + * deliberately. That module reads `x-ratelimit-reset` as UTC epoch seconds, + * which is what GitHub and X document; the OpenAI family spells the same idea as + * `x-ratelimit-reset-tokens` carrying a Go duration. Teaching the shared reader + * both spellings would change the retry behaviour of every connector that goes + * through `fetchWithRetry`, so the provider-specific reading stays here. + */ +interface HeaderReader { + get(name: string): string | null +} + +/** Milliseconds per Go duration unit. Longer spellings first — `ms` must win over `m`. */ +const GO_DURATION_UNITS = [ + ['ns', 1e-6], + ['us', 1e-3], + ['µs', 1e-3], + ['ms', 1], + ['h', 3_600_000], + ['m', 60_000], + ['s', 1000], +] as const + +const GO_DURATION_PART = /(\d+(?:\.\d+)?)(ns|us|µs|ms|h|m|s)/gy + +/** + * Parses a Go duration such as `6m0s`, `12ms`, or `23h47m36.648s` — the format + * OpenAI uses for its rate-limit reset headers — into milliseconds. + * + * Returns `null` for anything the format does not fully describe, so a value in + * some other shape falls back to backoff rather than being half-read. The sticky + * match and the end-of-input check are what enforce that: `m` and `ms` share a + * prefix, so a parser scanning loosely would read `12ms` as twelve minutes. + */ +export function parseGoDurationMs(value: string): number | null { + const trimmed = value.trim() + if (trimmed.length === 0) return null + + GO_DURATION_PART.lastIndex = 0 + let total = 0 + let matched = false + + while (GO_DURATION_PART.lastIndex < trimmed.length) { + const part = GO_DURATION_PART.exec(trimmed) + if (!part) return null + const unit = GO_DURATION_UNITS.find(([name]) => name === part[2]) + if (!unit) return null + total += Number(part[1]) * unit[1] + matched = true + } + + return matched ? total : null +} + +/** Paired remaining/reset headers, per rate-limited dimension. */ +const OPENAI_LIMIT_DIMENSIONS = [ + { remaining: 'x-ratelimit-remaining-tokens', reset: 'x-ratelimit-reset-tokens' }, + { remaining: 'x-ratelimit-remaining-requests', reset: 'x-ratelimit-reset-requests' }, +] as const + +/** + * Resolves the stated wait for a rejected embedding request. + * + * `Retry-After` wins when present, since the provider is naming the wait + * directly. Otherwise the reset header is read for whichever dimension is + * actually exhausted — a tokens-per-minute rejection and a requests-per-minute + * rejection reopen at different times, and the reset for a dimension with quota + * left says nothing about when this request may be retried. When both are + * exhausted the longer wait is the one that governs. + */ +export function resolveEmbeddingRetryDelayMs(headers: HeaderReader): number | null { + const retryAfterMs = parseRetryAfter(headers.get('retry-after'), Number.POSITIVE_INFINITY) + if (retryAfterMs !== null && retryAfterMs > 0) return retryAfterMs + + let longest: number | null = null + for (const dimension of OPENAI_LIMIT_DIMENSIONS) { + if (headers.get(dimension.remaining) !== '0') continue + const reset = headers.get(dimension.reset) + if (!reset) continue + const waitMs = parseGoDurationMs(reset) + if (waitMs === null || waitMs <= 0) continue + longest = longest === null ? waitMs : Math.max(longest, waitMs) + } + + return longest +} diff --git a/apps/sim/lib/folders/subtree.ts b/apps/sim/lib/folders/subtree.ts index 42e727dd9cc..ccf2da25dd3 100644 --- a/apps/sim/lib/folders/subtree.ts +++ b/apps/sim/lib/folders/subtree.ts @@ -15,7 +15,7 @@ export type FolderChildrenIndex = ReadonlyMap * it on every call, which is O(rows) each time, and a workspace's tree is * bounded only by `MAX_FOLDERS_PER_WORKSPACE`. */ -export function indexFolderChildren(folders: readonly FolderNode[]): FolderChildrenIndex { +export function indexFolderChildren(folders: Iterable): FolderChildrenIndex { const childrenByParent = new Map() for (const folder of folders) { diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 14e3bb7f13a..c35f3b3d303 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -33,6 +33,7 @@ import type { ExternalDocument, SyncResult, } from '@/connectors/types' +import { hasIndexablePayload } from '@/connectors/utils' const logger = createLogger('ConnectorSyncEngine') @@ -157,7 +158,7 @@ export function classifyExternalDoc( if (extDoc.skippedReason) { return existing ? { type: 'unchanged' } : { type: 'skip' } } - if (!hasPayload(extDoc) && !extDoc.contentDeferred) { + if (!hasIndexablePayload(extDoc) && !extDoc.contentDeferred) { return { type: 'drop' } } if (!existing) { @@ -203,11 +204,6 @@ export function mergeHydratedDocument( } } -/** Whether a document carries anything to index — extracted text or the source file. */ -function hasPayload(extDoc: Pick): boolean { - return extDoc.sourceFile !== undefined || extDoc.content.trim().length > 0 -} - /** Estimated source bytes for a pending op, taken from its listing metadata. */ function estimateOpSizeBytes(op: DocOp): number { // Skip ops load no content (just a row insert), so they do not count against the @@ -1093,7 +1089,7 @@ export async function executeSync( } return null } - if (!fullDoc || !hasPayload(fullDoc)) { + if (!fullDoc || !hasIndexablePayload(fullDoc)) { // An empty re-fetch leaves an already-indexed update as last-known-good; count // it as unchanged so the totals still reconcile with documents seen. Not a // verified refresh, though — see failedExternalIds below. diff --git a/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts b/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts index 75751211211..d3386f69946 100644 --- a/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts @@ -91,7 +91,17 @@ describe('knowledge document model-input provenance', () => { expect(fetchMock).not.toHaveBeenCalled() }) + /** + * The refusal guards egress to an external model, so it is asserted against the + * outbound request rather than the storage read. A PDF is now parsed locally + * first and only reaches OCR when it has no usable text layer — local parsing is + * not model input, as the case above establishes — so the bytes are read before + * the projection is checked, and never leave the worker when it refuses. + */ it('rejects secret-bearing opaque document bytes before external OCR', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + await expect( runWithKnowledgeModelInputProvenance( undefined, @@ -109,7 +119,7 @@ describe('knowledge document model-input provenance', () => { ) ).rejects.toThrow('Knowledge model input could not be safely projected') - expect(mockDownloadFileFromUrl).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() }) it('attaches exact-empty provenance to the internal Mistral OCR request', async () => { diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 542b84af54c..ebea0141279 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -22,6 +22,7 @@ import { resolveParserExtension, resolveStoredArtifactExtension, } from '@/lib/knowledge/documents/parser-extension' +import { assessPdfTextLayer } from '@/lib/knowledge/documents/pdf-text-layer' import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' import { assertKnowledgeOpaqueModelInputSafe, @@ -295,6 +296,68 @@ async function getMistralApiKey(workspaceId?: string | null): Promise { + try { + const buffer = await downloadFileWithTimeout(fileUrl, userId) + const parsed = await parseBuffer(buffer, 'pdf') + + /** + * The page count comes from the same parse as the text, rather than a second + * independent read of the file. Counting separately lets the two disagree: a + * count that failed would report no pages, the density check would fall back to + * treating the document as a single page, and a long scan carrying only a header + * would look dense enough to skip OCR and be indexed as that header. + */ + const pageCount = parsed.metadata?.pageCount ?? 0 + const verdict = assessPdfTextLayer(parsed.content, pageCount, parsed.metadata?.truncated) + if (!verdict.usable) { + logger.info('PDF text layer not usable, routing to OCR', { + filename, + pageCount, + reason: verdict.reason, + }) + return undefined + } + + logger.info('Using embedded PDF text layer', { filename, pageCount }) + return { + content: parsed.content, + processingMethod: 'file-parser', + cloudUrl: undefined, + metadata: parsed.metadata, + } + } catch (error) { + logger.info('Could not read PDF text layer, routing to OCR', { + filename, + mimeType, + error: toError(error).message, + }) + return undefined + } +} + async function parseDocument( fileUrl: string, filename: string, @@ -319,14 +382,23 @@ async function parseDocument( MISTRAL_API_KEY: mistralApiKey, }).providerId - if (ocrProvider === 'azure-mistral') { - assertKnowledgeOpaqueModelInputSafe() - logger.info('Using Azure Mistral OCR') - return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId) - } + if (ocrProvider === 'azure-mistral' || ocrProvider === 'mistral') { + /** + * Most PDFs carry a usable text layer, and reading it costs nothing. OCR is + * a per-document call to an external service, so it is reserved for the + * documents that actually need it — which also means everything else stops + * depending on that service being reachable. + */ + const embedded = await readEmbeddedPdfText(fileUrl, filename, mimeType, userId) + if (embedded) return embedded - if (ocrProvider === 'mistral') { assertKnowledgeOpaqueModelInputSafe() + + if (ocrProvider === 'azure-mistral') { + logger.info('Using Azure Mistral OCR') + return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId) + } + logger.info('Using Mistral OCR') return parseWithMistralOCR(fileUrl, filename, mimeType, userId, workspaceId, mistralApiKey) } @@ -522,42 +594,19 @@ async function parseWithAzureMistralOCR( const fileBuffer = await downloadFileForBase64(fileUrl, userId) - if (mimeType === 'application/pdf') { - const pageCount = await getPdfPageCount(fileBuffer) - if (pageCount > MISTRAL_MAX_PAGES) { - throw new Error( - `PDF has ${pageCount} pages, exceeding the Azure OCR limit of ${MISTRAL_MAX_PAGES}` - ) - } - logger.info('Azure Mistral OCR: PDF page count resolved', { pageCount }) - } - - const base64Data = fileBuffer.toString('base64') - const dataUri = `data:${mimeType};base64,${base64Data}` - try { - const response = await retryWithExponentialBackoff( - () => - makeOCRRequest( - env.OCR_AZURE_ENDPOINT!, - { - 'Content-Type': 'application/json', - Authorization: `Bearer ${env.OCR_AZURE_API_KEY}`, - }, - { - model: env.OCR_AZURE_MODEL_NAME!, - document: { - type: 'document_url', - document_url: dataUri, - }, - include_image_base64: false, - } - ), - { maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 10000 } - ) - - const ocrResult = (await response.json()) as AzureOCRResponse - const content = extractPageContent(ocrResult.pages || []) || JSON.stringify(ocrResult, null, 2) + /** + * A PDF is chunked to the provider's page cap rather than refused for + * exceeding it, matching the other OCR provider. Refusing meant a long + * document could not be ingested at all, and the cap applies to a single + * request, not to the document. + */ + const content = + mimeType === 'application/pdf' + ? await ocrPdfInChunks(fileBuffer, 'azure-mistral', (chunk) => + recognizeWithAzureOCR(chunk.buffer, mimeType) + ) + : await recognizeWithAzureOCR(fileBuffer, mimeType) if (!content.trim()) { throw new Error('Azure Mistral OCR returned empty content') @@ -573,6 +622,41 @@ async function parseWithAzureMistralOCR( } } +/** Sends one document to Azure Mistral OCR inline, as a base64 data URI. */ +async function recognizeWithAzureOCR(buffer: Buffer, mimeType: string): Promise { + const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}` + + const response = await retryWithExponentialBackoff( + () => + makeOCRRequest( + env.OCR_AZURE_ENDPOINT!, + { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.OCR_AZURE_API_KEY}`, + }, + { + model: env.OCR_AZURE_MODEL_NAME!, + document: { + type: 'document_url', + document_url: dataUri, + }, + include_image_base64: false, + } + ), + { maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 10000 } + ) + + const ocrResult = (await response.json()) as AzureOCRResponse + + /** + * A response carrying no pages is no content. Returning the raw payload instead + * would be indexed as though it were the document: stitched into a chunked run + * as recovered text, and in a single-document run it would satisfy the + * empty-content check that exists to catch exactly this. + */ + return extractPageContent(ocrResult.pages || []) +} + async function parseWithMistralOCR( fileUrl: string, filename: string, @@ -740,63 +824,118 @@ async function processChunk( } } -async function processMistralOCRInBatches( - filename: string, - apiKey: string, +/** + * Runs a PDF through OCR a chunk at a time and stitches the pages back together. + * + * A provider that caps how many pages one request may carry needs the document + * split, and both providers cap at the same limit — so the splitting, the + * concurrency, the ordering and the partial-failure rule live here once rather + * than being restated per provider, where they had already drifted into one + * provider chunking and the other refusing anything over the cap. + * + * A document is indexed whole or not at all: if any chunk fails, the document + * fails, because a partial result reports success while page ranges are missing + * and nothing downstream can tell. + */ +async function ocrPdfInChunks( pdfBuffer: Buffer, - userId?: string, - cloudUrl?: string -): Promise<{ - content: string - processingMethod: 'mistral-ocr' - cloudUrl?: string -}> { + provider: string, + recognize: ( + chunk: { buffer: Buffer; startPage: number; endPage: number }, + chunkIndex: number, + totalChunks: number + ) => Promise +): Promise { const totalPages = await getPdfPageCount(pdfBuffer) - logger.info(`Splitting PDF into chunks`, { totalPages, maxPagesPerChunk: MISTRAL_MAX_PAGES }) - const pdfChunks = await splitPdfIntoChunks(pdfBuffer, MISTRAL_MAX_PAGES) - logger.info( - `Split into ${pdfChunks.length} chunks, processing with concurrency ${MAX_CONCURRENT_CHUNKS}` - ) + /** + * Splitting has to load the document, which an encrypted or malformed PDF will + * refuse. That must not decide whether the file reaches OCR at all: those are + * exactly the documents with no readable text layer, so OCR is their only route, + * and the provider may well accept bytes that a local parser would not. When the + * split fails the document is sent whole and the page cap is left to the + * provider — the behaviour before it was chunked. + */ + let pdfChunks: { buffer: Buffer; startPage: number; endPage: number }[] + try { + pdfChunks = await splitPdfIntoChunks(pdfBuffer, MISTRAL_MAX_PAGES) + } catch (error) { + logger.info('PDF could not be split for OCR, sending it whole', { + provider, + error: toError(error).message, + }) + pdfChunks = [{ buffer: pdfBuffer, startPage: 0, endPage: Math.max(0, totalPages - 1) }] + } + + logger.info('Splitting PDF for OCR', { + provider, + totalPages, + chunks: pdfChunks.length, + maxPagesPerChunk: MISTRAL_MAX_PAGES, + concurrency: MAX_CONCURRENT_CHUNKS, + }) const results: { index: number; content: string | null }[] = [] for (let i = 0; i < pdfChunks.length; i += MAX_CONCURRENT_CHUNKS) { const batch = pdfChunks.slice(i, i + MAX_CONCURRENT_CHUNKS) - const batchPromises = batch.map((chunk, batchIndex) => - processChunk(chunk, i + batchIndex, pdfChunks.length, filename, apiKey, userId) - ) - - const batchResults = await Promise.all(batchPromises) - for (const result of batchResults) { - results.push(result) - } - - logger.info( - `Completed batch ${Math.floor(i / MAX_CONCURRENT_CHUNKS) + 1}/${Math.ceil(pdfChunks.length / MAX_CONCURRENT_CHUNKS)}` + const batchResults = await Promise.all( + batch.map((chunk, batchIndex) => { + const index = i + batchIndex + return recognize(chunk, index, pdfChunks.length).then( + (content) => ({ index, content }), + (error) => { + logger.warn('OCR chunk failed', { + provider, + chunk: index + 1, + error: toError(error).message, + }) + return { index, content: null } + } + ) + }) ) + results.push(...batchResults) } - const sortedResults = results + const recovered = results .sort((a, b) => a.index - b.index) - .filter((r) => r.content !== null) - .map((r) => r.content as string) - - if (sortedResults.length === 0) { + .map((r) => r.content) + .filter((content): content is string => content !== null && content.trim().length > 0) + + /** + * Each chunk has already exhausted its own retries, so a missing one is a real + * failure rather than a blip. Failing the document leaves it visible with a + * reason and eligible for the stuck-document sweep, which can retry it and + * produce a complete result — whereas indexing what came back would be + * indistinguishable from a document that never had those pages. + */ + if (recovered.length < pdfChunks.length) { throw new Error( - `OCR failed for all ${pdfChunks.length} chunks. ` + - `Large PDFs require OCR - file parser fallback would produce poor results.` + `OCR recovered ${recovered.length} of ${pdfChunks.length} chunks; ` + + 'indexing the document would omit the rest' ) } - const combinedContent = sortedResults.join('\n\n') - logger.info(`Successfully processed ${sortedResults.length}/${pdfChunks.length} chunks`) + return recovered.join('\n\n') +} - return { - content: combinedContent, - processingMethod: 'mistral-ocr', - cloudUrl, - } +async function processMistralOCRInBatches( + filename: string, + apiKey: string, + pdfBuffer: Buffer, + userId?: string, + cloudUrl?: string +): Promise<{ + content: string + processingMethod: 'mistral-ocr' + cloudUrl?: string +}> { + const content = await ocrPdfInChunks(pdfBuffer, 'mistral', (chunk, index, total) => + processChunk(chunk, index, total, filename, apiKey, userId).then((r) => r.content) + ) + + return { content, processingMethod: 'mistral-ocr', cloudUrl } } /** diff --git a/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts new file mode 100644 index 00000000000..48a29293d47 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + * + * Every PDF used to be sent to OCR, an external per-document call, even though the + * large majority carry a usable text layer that costs nothing to read. These pin + * the routing: the text layer is tried first, and OCR is reached only when it is + * missing or unreadable. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockParseBuffer, mockDownload, mockToken, mockBaseUrl } = vi.hoisted(() => ({ + mockParseBuffer: vi.fn(), + mockDownload: vi.fn(), + mockToken: vi.fn(), + mockBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken })) +vi.mock('@/lib/core/utils/urls', async (importOriginal) => ({ + ...(await importOriginal()), + getInternalApiBaseUrl: mockBaseUrl, +})) + +vi.mock('@/lib/file-parsers', () => ({ + parseBuffer: mockParseBuffer, + isSupportedFileType: (extension: string) => ['pdf'].includes(extension), +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ downloadFileFromUrl: mockDownload })) + +import { env } from '@/lib/core/config/env' +import { processDocument } from '@/lib/knowledge/documents/document-processor' +import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' + +/** External, so the OCR path uses the URL directly instead of re-uploading it. */ +const PDF_URL = 'https://example.com/Contract.pdf' +const typeset = 'The Supplier shall provide the Services described herein. '.repeat(60) + +/** A real PDF, because splitting loads the document rather than trusting metadata. */ +async function pdfOfPages(count: number): Promise { + const { PDFDocument } = await import('pdf-lib') + const pdf = await PDFDocument.create() + for (let i = 0; i < count; i++) pdf.addPage() + return Buffer.from(await pdf.save()) +} + +function parse() { + return runWithKnowledgeModelInputProvenance( + undefined, + () => processDocument(PDF_URL, 'Contract.pdf', 'application/pdf', 1024, 200, 1, 'user-1'), + { opaqueInputSafe: true } + ) +} + +describe('PDF OCR triage', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(env, { OCR_PROVIDER: 'mistral', MISTRAL_API_KEY: 'key' }) + mockDownload.mockResolvedValue(Buffer.from('%PDF-1.7')) + mockToken.mockResolvedValue('internal-token') + mockBaseUrl.mockReturnValue('http://sim.local') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses the embedded text layer and never calls OCR', async () => { + mockParseBuffer.mockResolvedValue({ content: typeset, metadata: {} }) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('file-parser') + expect(fetchMock).not.toHaveBeenCalled() + }) + + /** + * The density check reads its page count from the same parse as the text. A long + * scan that yields only a header must stay sparse against its real page count — + * counting separately allowed a failed count to present it as a single dense page. + */ + it('takes the page count from the parse, so a header-only scan stays sparse', async () => { + // Enough to clear the floor as a single page, nowhere near enough for 80. + const headerOnly = 'CONFIDENTIAL - Vendor Master Agreement - Page header. '.repeat(6) + mockParseBuffer.mockResolvedValue({ content: headerOnly, metadata: { pageCount: 80 } }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + }) + + it('falls through to OCR when the PDF is a scan', async () => { + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised text' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + // 1001 pages against a 1000-page request cap: two chunks, two requests. + expect(fetchMock).toHaveBeenCalled() + }) + + /** + * The case a length check alone cannot see: a CID-keyed font with no Unicode map + * yields plenty of characters, none of them words. + */ + it('falls through to OCR when the text layer is raw CID escapes', async () => { + mockParseBuffer.mockResolvedValue({ content: '/31 /8 /18 /12 /44 '.repeat(60), metadata: {} }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + }) + + /** An encrypted or malformed PDF has no readable layer, which is a case for OCR. */ + it('falls through to OCR when the text layer cannot be parsed at all', async () => { + mockParseBuffer.mockRejectedValue(new Error('Invalid PDF structure.')) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + }) +}) + +describe('Azure OCR chunking', () => { + /** + * Both providers cap how many pages one OCR request may carry. Mistral split the + * document to fit; Azure refused anything over the cap, so a long PDF could not + * be ingested at all. The cap belongs to a request, not to a document. + */ + it('splits a PDF past the page cap instead of refusing it', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: { pageCount: 2500 } }) + mockDownload.mockResolvedValue(await pdfOfPages(1001)) + // A fresh Response per call: a body can only be read once. + const fetchMock = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised page' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + // 1001 pages against a 1000-page request cap: two chunks, two requests. + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + /** + * Splitting loads the document, which an encrypted or malformed PDF refuses. + * Those are precisely the files the triage sends here — no readable text layer — + * so a failed split must not decide whether they reach OCR at all. + */ + it('sends a PDF that cannot be split whole rather than refusing it', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockRejectedValue(new Error('Invalid PDF structure.')) + mockDownload.mockResolvedValue(Buffer.from('not something pdf-lib can load')) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + /** + * A response with no pages is no content. Returning the raw payload would index + * the API envelope as the document and satisfy the empty-content check meant to + * catch it. + */ + it('treats an Azure response carrying no pages as empty, not as content', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + mockDownload.mockResolvedValue(await pdfOfPages(2)) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [], usage_info: { pages_processed: 0 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + // Counted as a failed chunk rather than stitched in as recovered text. + await expect(parse()).rejects.toThrow(/OCR recovered 0 of 1 chunks/) + }) + + /** + * A document is indexed whole or not at all. Returning the chunks that did come + * back would mark the document complete with whole page ranges missing from + * search, and nothing downstream could tell it apart from a complete one. + */ + it('fails the document when one chunk of several fails', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + mockDownload.mockResolvedValue(await pdfOfPages(1001)) + + let call = 0 + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => { + call++ + if (call === 1) { + return new Response(JSON.stringify({ pages: [{ markdown: 'First half' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response('upstream failure', { status: 500 }) + }) + ) + + await expect(parse()).rejects.toThrow(/OCR recovered 1 of 2 chunks/) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/pdf-text-layer.test.ts b/apps/sim/lib/knowledge/documents/pdf-text-layer.test.ts new file mode 100644 index 00000000000..9b759ee9b0c --- /dev/null +++ b/apps/sim/lib/knowledge/documents/pdf-text-layer.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { assessPdfTextLayer } from '@/lib/knowledge/documents/pdf-text-layer' + +/** Roughly the character volume of a typeset page. */ +const page = (n: number) => + 'The Supplier shall provide the Services described in this Statement of Work. '.repeat(n) + +describe('assessPdfTextLayer', () => { + it('accepts an ordinary typeset document', () => { + expect(assessPdfTextLayer(page(60), 2)).toEqual({ usable: true }) + }) + + /** + * A parser limit stops extraction partway, so the text that came back is plenty + * by volume but is only part of the document. Accepting it would index a + * fragment and drop the rest from search without saying so. + */ + it('rejects an extraction that stopped at a parser limit', () => { + expect(assessPdfTextLayer(page(200), 3, true)).toEqual({ usable: false, reason: 'truncated' }) + }) + + it('rejects a scan, which carries no text at all', () => { + expect(assessPdfTextLayer('', 12)).toEqual({ usable: false, reason: 'no-text' }) + expect(assessPdfTextLayer(' \n ', 12)).toEqual({ usable: false, reason: 'no-text' }) + }) + + /** A scan often still yields a header or a stamp — present, but not the content. */ + it('rejects text too sparse to be the document', () => { + expect(assessPdfTextLayer('CONFIDENTIAL', 40)).toEqual({ + usable: false, + reason: 'sparse-text', + }) + }) + + /** + * A CID-keyed font with no `ToUnicode` map extracts as raw character ids. There + * is plenty of it, so a length check passes and the document would be indexed as + * gibberish — the failure mode a characters-per-page test alone cannot see. + */ + it('rejects raw CID escapes from a font with no Unicode mapping', () => { + const cid = '/31 /8 /18 /12 /44 /9 /27 /15 /3 /62 '.repeat(40) + + expect(assessPdfTextLayer(cid, 1)).toEqual({ usable: false, reason: 'cid-escapes' }) + }) + + it('rejects a text layer that decoded to replacement characters', () => { + expect(assessPdfTextLayer('�'.repeat(500), 1)).toEqual({ + usable: false, + reason: 'unreadable-encoding', + }) + }) + + /** Real prose contains slashes and digits; only a dominant share is disqualifying. */ + it('keeps a document that merely mentions figures and dates', () => { + const prose = `${page(40)} Payment of /50 net 30, effective 01/04/2026, ref /12 /9.` + + expect(assessPdfTextLayer(prose, 1)).toEqual({ usable: true }) + }) + + it('keeps accented and non-Latin prose, which is ordinary text', () => { + expect(assessPdfTextLayer('Zusammenfassung über Verträge. '.repeat(40), 1)).toEqual({ + usable: true, + }) + expect(assessPdfTextLayer('契約の概要について説明します。'.repeat(40), 1)).toEqual({ + usable: true, + }) + }) + + /** An unparseable page count must still apply a floor rather than divide by zero. */ + it('treats an unknown page count as a single page', () => { + expect(assessPdfTextLayer('short', 0)).toEqual({ usable: false, reason: 'sparse-text' }) + expect(assessPdfTextLayer(page(40), 0)).toEqual({ usable: true }) + }) + + it('scales the threshold with length, so one good page does not carry a long scan', () => { + const onePageOfText = page(30) + + expect(assessPdfTextLayer(onePageOfText, 1)).toEqual({ usable: true }) + expect(assessPdfTextLayer(onePageOfText, 200)).toEqual({ + usable: false, + reason: 'sparse-text', + }) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/pdf-text-layer.ts b/apps/sim/lib/knowledge/documents/pdf-text-layer.ts new file mode 100644 index 00000000000..ec7f42804fd --- /dev/null +++ b/apps/sim/lib/knowledge/documents/pdf-text-layer.ts @@ -0,0 +1,104 @@ +/** + * Minimum average characters per page for an embedded text layer to be trusted. + * + * A typeset page carries roughly 1,500–3,000 characters and a scanned image + * carries none, so this sits an order of magnitude below real prose and well above + * the handful of characters a scan contributes from a header or a stamp. The gap + * either side is wide enough that the exact value does not matter. + */ +const MIN_CHARS_PER_PAGE = 100 + +/** + * Share of characters that must be ordinary printable text. + * + * A text layer with a broken encoding extracts as mojibake or replacement + * characters: present in quantity, but not words. + */ +const MIN_PRINTABLE_RATIO = 0.8 + +/** + * Share of characters that may look like CID escapes before the layer is rejected. + * + * A CID-keyed font with no `ToUnicode` map extracts as the raw character ids — + * `/31 /8 /18 /12` — rather than glyphs. It passes a length check comfortably while + * containing no readable text at all. Common in documents from older generators and + * in anything using subset fonts, which is much of the contract and procurement + * material that reaches a knowledge base. + */ +const MAX_CID_ESCAPE_RATIO = 0.5 + +/** Runs of the form `/31 /8`, the raw output of a CID font with no Unicode map. */ +const CID_ESCAPE_PATTERN = /\/i?\d+/g + +/** Characters that count as ordinary text: printable ASCII, whitespace, and Latin-1+. */ +const PRINTABLE_PATTERN = /[\p{L}\p{N}\p{P}\p{Zs}\n\r\t]/gu + +export type PdfTextLayerVerdict = + | { usable: true } + | { + usable: false + reason: 'no-text' | 'truncated' | 'sparse-text' | 'unreadable-encoding' | 'cid-escapes' + } + +function countMatches(text: string, pattern: RegExp): number { + let total = 0 + for (const match of text.matchAll(pattern)) total += match[0].length + return total +} + +/** + * Judges whether a PDF's embedded text layer can be indexed as-is, or whether the + * document has to go through OCR to be readable. + * + * Extracting a text layer costs nothing and covers the large majority of PDFs; + * OCR is a per-document call to an external service. Asking this question first + * means only the documents that actually need OCR pay for it, and it removes the + * dependency on that service for everything else. + * + * Four ways a text layer fails, none caught by the others: there is no text (a + * scan), extraction stopped at a parser limit so what came back is only part of + * the document, the text is too sparse to be the document's real content, or + * there is plenty of text but it is not language — a broken encoding, or raw CID + * codes from a font with no Unicode mapping. + * + * Known limitation: this judges the document as a whole, so a file that mixes + * typeset pages with scanned inserts can average out above the threshold and keep + * its partial text. Routing per page would catch that, and needs per-page + * extraction this does not currently have. + */ +export function assessPdfTextLayer( + text: string, + pageCount: number, + truncated = false +): PdfTextLayerVerdict { + const trimmed = text.trim() + if (trimmed.length === 0) return { usable: false, reason: 'no-text' } + + /** + * Checked before anything measuring volume, because a truncated extraction has + * plenty of text by definition and would otherwise read as healthy. Accepting it + * would index part of a document and silently drop the rest from search, so the + * document goes to OCR, which reads it whole. + */ + if (truncated) return { usable: false, reason: 'truncated' } + + /** + * An unknown page count (a PDF whose header would not parse) is treated as a + * single page: it still applies a floor, without inventing a page count that + * would scale the threshold arbitrarily. + */ + const pages = pageCount > 0 ? pageCount : 1 + if (trimmed.length / pages < MIN_CHARS_PER_PAGE) return { usable: false, reason: 'sparse-text' } + + const cidChars = countMatches(trimmed, CID_ESCAPE_PATTERN) + if (cidChars / trimmed.length > MAX_CID_ESCAPE_RATIO) { + return { usable: false, reason: 'cid-escapes' } + } + + const printableChars = countMatches(trimmed, PRINTABLE_PATTERN) + if (printableChars / trimmed.length < MIN_PRINTABLE_RATIO) { + return { usable: false, reason: 'unreadable-encoding' } + } + + return { usable: true } +} diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 5a209ddc72a..2c5904773a4 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -297,10 +297,19 @@ function withTimeout( ]) } +/** + * Limits for the in-process document path. + * + * Both values used to be derived from variables owned by other subsystems — + * documents-at-once from the task-queue depth, documents-per-batch from the + * chunks-per-embedding-request size — so tuning either of those silently moved + * this one too, by a factor set by the divisor rather than by intent. The + * divisors are gone and the previous effective values (4 and 10) are now the + * declared defaults. + */ const PROCESSING_CONFIG = { - maxConcurrentDocuments: - Math.max(1, Math.floor(envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20) / 5)) || 4, - batchSize: Math.max(1, Math.floor(envNumber(env.KB_CONFIG_BATCH_SIZE, 20) / 2)) || 10, + maxConcurrentDocuments: envNumber(env.KB_CONFIG_DOCUMENT_CONCURRENCY, 4, { min: 1 }), + batchSize: envNumber(env.KB_CONFIG_DOCUMENT_BATCH_SIZE, 10, { min: 1 }), delayBetweenBatches: envNumber(env.KB_CONFIG_DELAY_BETWEEN_BATCHES, 100) * 2, delayBetweenDocuments: envNumber(env.KB_CONFIG_DELAY_BETWEEN_DOCUMENTS, 50) * 2, } diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index b06cd09aaf0..5f56fa97c95 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -63,8 +63,9 @@ beforeAll(() => { afterAll(resetEnvMock) +import { GoogleIcon, GoogleVaultIcon } from '@/components/icons' import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' -import { refreshOAuthToken } from '@/lib/oauth' +import { OAUTH_PROVIDERS, refreshOAuthToken } from '@/lib/oauth' import { REDDIT_USER_AGENT } from '@/tools/reddit/constants' /** @@ -90,6 +91,15 @@ function withMockFetch(mockFetch: ReturnType, fn: () => Promise }) } +describe('OAuth Provider Branding', () => { + it('should use the Google Vault product icon and Google base-provider icon', () => { + const googleVault = OAUTH_PROVIDERS.google.services['google-vault'] + + expect(googleVault.icon).toBe(GoogleVaultIcon) + expect(googleVault.baseProviderIcon).toBe(GoogleIcon) + }) +}) + describe('OAuth Token Refresh', () => { describe('Basic Auth Providers', () => { const basicAuthProviders = [ diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 300c2376ffc..1414440c3fb 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -27,6 +27,7 @@ import { GoogleMeetIcon, GoogleSheetsIcon, GoogleTasksIcon, + GoogleVaultIcon, HubspotIcon, InstagramIcon, JiraIcon, @@ -255,7 +256,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Google Vault', description: 'Search, export, and manage matters/holds via Google Vault.', providerId: 'google-vault', - icon: GoogleIcon, + icon: GoogleVaultIcon, baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/userinfo.email', diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 9531643db52..2b67cf01897 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -14,7 +14,7 @@ import { getPostgresErrorCode, } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' import type { ListSortOrder } from '@/lib/api/list-query' @@ -1252,7 +1252,14 @@ export interface QueryWorkspaceFilesOptions { scope?: WorkspaceFileScope /** Restrict to one file folder. */ /** `undefined` lists every folder, `null` lists only root files. */ - folderId?: string | null + /** + * The folder to match: one id, `null` for the workspace root, or several ids for a folder + * and its descendants. Omit to match every folder. + * + * An empty array matches nothing — the honest answer for an empty set of folders, and the + * shape Drizzle already emits (`false`) for an empty `IN`. + */ + folderId?: string | null | readonly string[] /** Case-insensitive substring match on the file name. */ search?: string sortBy: V2FileSortBy @@ -1268,6 +1275,16 @@ export interface QueryWorkspaceFilesResult { nextKeys: CursorKey[] | null } +/** The folder predicate for {@link QueryWorkspaceFilesOptions.folderId}'s three shapes. */ +function workspaceFileFolderCondition( + folderId: string | null | readonly string[] | undefined +): SQL | undefined { + if (folderId === undefined) return undefined + if (folderId === null) return isNull(workspaceFiles.folderId) + if (Array.isArray(folderId)) return inArray(workspaceFiles.folderId, folderId) + return eq(workspaceFiles.folderId, folderId as string) +} + /** * One filtered, sorted, bounded page of a workspace's files. * @@ -1297,11 +1314,7 @@ export async function queryWorkspaceFiles( const conditions = [ workspaceFileScopeCondition(workspaceId, scope), - folderId === undefined - ? undefined - : folderId === null - ? isNull(workspaceFiles.folderId) - : eq(workspaceFiles.folderId, folderId), + workspaceFileFolderCondition(folderId), searchFilter(workspaceFiles.originalName, search), resumeAfter, ] diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.test.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.test.ts new file mode 100644 index 00000000000..51dd6dbbe3d --- /dev/null +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + loadFolderIndex: vi.fn(), + queryFiles: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: vi.fn(), + loadActiveWorkspaceContext: mocks.loadWorkspace, + queryWorkspaceFiles: mocks.queryFiles, +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ getWorkspaceShares: vi.fn() })) + +vi.mock('@/lib/folders/queries', async () => { + const { resolveFolderPathFilter } = + await vi.importActual('@/lib/folders/queries') + return { loadActiveFolderPathIndex: mocks.loadFolderIndex, resolveFolderPathFilter } +}) + +import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' + +/** + * Projects / (a) + * └── Q3 (b) + * └── Drafts (c) + * Archive (d) + */ +const ROWS = [ + { id: 'a', name: 'Projects', parentId: null }, + { id: 'b', name: 'Q3', parentId: 'a' }, + { id: 'c', name: 'Drafts', parentId: 'b' }, + { id: 'd', name: 'Archive', parentId: null }, +] + +/** `files.list` accepts a session principal — see `ALL_COPILOT_PRINCIPAL_POLICY`. */ +const principal = { + kind: 'session' as const, + userId: 'user-1', + workspaceId: 'workspace-1', +} + +function buildIndex() { + const rowById = new Map(ROWS.map((row) => [row.id, row])) + const pathById = new Map([ + ['a', '/Projects'], + ['b', '/Projects/Q3'], + ['c', '/Projects/Q3/Drafts'], + ['d', '/Archive'], + ]) + const idByPath = new Map([...pathById].map(([id, path]) => [path, id])) + return { rowById, pathById, idByPath } +} + +const baseInput = { + workspaceId: 'workspace-1', + sortBy: 'uploadedAt' as const, + sortOrder: 'asc' as const, + limit: 100, +} + +type PageInput = Parameters[0]['input'] + +async function execute(input: Partial = {}) { + return queryWorkspaceFilePage.execute({ + input: { ...baseInput, ...input } as PageInput, + principal, + }) +} + +/** The folder scoping the use case handed to the query layer. */ +async function run(input: Partial = {}) { + await execute(input) + return mocks.queryFiles.mock.calls.at(-1)?.[1] +} + +describe('queryWorkspaceFilePage folder scoping', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) + mocks.loadFolderIndex.mockResolvedValue(buildIndex()) + mocks.queryFiles.mockResolvedValue({ files: [], nextKeys: null }) + }) + + it('applies no folder predicate when folderPath is omitted', async () => { + const options = await run() + expect(options.folderId).toBeUndefined() + }) + + it('matches one folder when not recursive', async () => { + const options = await run({ folderPath: '/Projects' }) + expect(options.folderId).toBe('a') + }) + + it('matches the whole subtree when recursive', async () => { + const options = await run({ folderPath: '/Projects', recursive: true }) + expect(options.folderId).toEqual(['a', 'b', 'c']) + }) + + it('stops at the subtree it was asked for', async () => { + const options = await run({ folderPath: '/Projects/Q3', recursive: true }) + expect(options.folderId).toEqual(['b', 'c']) + }) + + it('includes a leaf folder itself', async () => { + const options = await run({ folderPath: '/Projects/Q3/Drafts', recursive: true }) + expect(options.folderId).toEqual(['c']) + }) + + it('treats a recursive root filter as the whole workspace, not root-level files', async () => { + const options = await run({ folderPath: '/', recursive: true }) + expect(options.folderId).toBeUndefined() + }) + + it('still means root-level files only when the root filter is not recursive', async () => { + const options = await run({ folderPath: '/' }) + expect(options.folderId).toBeNull() + }) + + it('returns an empty page for a folder that does not resolve', async () => { + const result = await execute({ folderPath: '/Nope', recursive: true }) + expect(result).toEqual({ files: [], nextKeys: null }) + expect(mocks.queryFiles).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index ac855b1ed57..a81f391ed9b 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -2,6 +2,7 @@ import type { CursorKey } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' +import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listWorkspaceFiles, @@ -21,6 +22,12 @@ export interface QueryWorkspaceFilePageInput { /** Lifecycle set to page over. Omission preserves the active-only default. */ scope?: 'active' | 'archived' folderPath?: string + /** + * Whether `folderPath` covers its whole subtree rather than its direct children. Ignored + * without a `folderPath`, which already spans the workspace. The surface decides the + * default — see the v2 route, where a `search` implies a recursive look. + */ + recursive?: boolean search?: string sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' sortOrder: 'asc' | 'desc' @@ -28,6 +35,30 @@ export interface QueryWorkspaceFilePageInput { after?: CursorKey[] } +/** + * Which folders a page covers, in the shape {@link queryWorkspaceFiles} takes: one id, `null` + * for the workspace root, several ids for a subtree, or `undefined` for the whole workspace. + * + * `unfiltered` (no `folderPath`) and a recursive filter on the root both mean the whole + * workspace, so both drop the folder predicate. A recursive filter on a real folder names + * every folder in its subtree, which the query takes as one `IN (...)` over the index already + * loaded for the path lookup — no second read, and no recursive CTE. + */ +function resolveFolderScope( + folderIndex: Awaited>, + folderFilter: ReturnType, + recursive: boolean | undefined +): string | null | string[] | undefined { + if (folderFilter.kind !== 'folder') return undefined + if (!recursive) return folderFilter.folderId + if (folderFilter.folderId === null) return undefined + const childrenByParent = indexFolderChildren(folderIndex.rowById.values()) + return [ + folderFilter.folderId, + ...collectDescendantFolderIdsFrom(childrenByParent, folderFilter.folderId), + ] +} + async function resolveListWorkspaceFileContext(workspaceId: string) { const workspace = await loadActiveWorkspaceContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') @@ -68,7 +99,7 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { scope: input.scope, - folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, + folderId: resolveFolderScope(folderIndex, folderFilter, input.recursive), search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, diff --git a/apps/sim/public/library/ai-agent-orchestration-frameworks-explained/cover.jpg b/apps/sim/public/library/ai-agent-orchestration-frameworks-explained/cover.jpg new file mode 100644 index 00000000000..8b93e0a39b6 Binary files /dev/null and b/apps/sim/public/library/ai-agent-orchestration-frameworks-explained/cover.jpg differ diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index bd8a5f3d9ea..c5e9b541799 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -2323,6 +2323,53 @@ describe('executeTool Function', () => { tools.function_execute = originalFunctionTool }) + it('gives an internal route transport headroom past its requested execution budget', async () => { + const originalFunctionTool = { ...tools.function_execute } + tools.function_execute = { + ...tools.function_execute, + transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), + } + + let observedSignal: AbortSignal | undefined + global.fetch = Object.assign( + vi.fn().mockImplementation( + async (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + observedSignal = init.signal as AbortSignal + observedSignal.addEventListener('abort', () => { + const err = new Error('aborted') + err.name = 'AbortError' + reject(err) + }) + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + vi.useFakeTimers() + try { + const resultPromise = executeTool( + 'function_execute', + { code: 'return 1', timeout: 5000 }, + { skipPostProcess: true } + ) + + // The route owns the 5s execution budget and needs to outlive it to report + // its own timeout, so the transport must still be waiting at that instant. + await vi.advanceTimersByTimeAsync(5000) + expect(observedSignal?.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(30_000) + const result = await resultPromise + + expect(result.success).toBe(false) + expect(result.error).toMatch(/timed out after 35000ms/) + } finally { + vi.useRealTimers() + tools.function_execute = originalFunctionTool + } + }) + it('should add timing information to results', async () => { const result = await executeTool( 'http_request', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 7bcae647256..b2c6ce262d4 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -953,6 +953,24 @@ import { normalizeToolId } from '@/tools/normalize' const MAX_REQUEST_BODY_SIZE_BYTES = 10 * 1024 * 1024 // 10MB const MAX_TOOL_RESPONSE_BODY_BYTES = 10 * 1024 * 1024 // 10MB +/** + * Headroom added to an internal route's requested timeout before it becomes the + * transport deadline. + * + * A `timeout` param bounds the work the route was asked to do — the code a + * sandbox runs, the upstream call a proxy route makes. The fetch around it also + * pays authentication, body parsing, workspace authorization, worker + * acquisition, and response serialization, none of which that budget was sized + * for. Arming the client with the bare number makes the caller give up at the + * same instant the route's own deadline fires, so the route can never win the + * race and report which part actually ran long; the caller sees an + * unattributable `Request timed out` instead. + * + * Sized above the isolated-vm worker's own 10s startup budget so a cold worker + * spawn stays inside the transport deadline rather than aborting it. + */ +const INTERNAL_ROUTE_TRANSPORT_OVERHEAD_MS = 30_000 + /** * User-friendly error message for body size limit exceeded */ @@ -2540,9 +2558,11 @@ async function executeToolRequest( let didTimeout = false // With a caller/execution abort signal present, the plan-based timeout bounds the call and // this only acts as a ceiling; without one, keep the tighter default as the hang safety net. - const timeout = - requestParams.timeout || - (signal ? getMaxExecutionTimeout() : DEFAULT_EXECUTION_TIMEOUT_MS) + const timeout = requestParams.timeout + ? requestParams.timeout + INTERNAL_ROUTE_TRANSPORT_OVERHEAD_MS + : signal + ? getMaxExecutionTimeout() + : DEFAULT_EXECUTION_TIMEOUT_MS const timeoutId = setTimeout(() => { didTimeout = true controller.abort(new DOMException('timeout', 'AbortError')) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 2cae5e43b6d..62bec70c50e 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -3461,6 +3461,19 @@ type ListFilesQueryRef0 = string export type ListFilesQuery = { workspaceId: string folderPath?: ListFilesQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' scope?: 'active' | 'archived' search?: string sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' @@ -7492,7 +7505,26 @@ export const V2_OPERATIONS = { folderPath: { kind: 'string', describe: - 'Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.', + 'Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.', + }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + describe: + 'Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.', }, scope: { kind: 'enum', diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index ef913731a9c..a93c3db63a8 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,49 +10,49 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 2919, + "modules": 2959, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, - "apps/sim/lib/auth/index.ts": 204 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1346, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1000, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 865, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 862, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 312, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 304, + "apps/sim/lib/auth/index.ts": 208 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1931, + "modules": 1968, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 337, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 280, - "apps/sim/lib/auth/index.ts": 210, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, - "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 100 + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 335, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 293, + "apps/sim/lib/auth/index.ts": 214, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 130, + "apps/sim/lib/api/contracts/index.ts": 108, + "apps/sim/lib/webhooks/providers/index.ts": 102 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 59, + "modules": 58, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 58, - "apps/sim/hooks/queries/workspace-files.ts": 55 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 57, + "apps/sim/hooks/queries/workspace-files.ts": 54 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1931, + "modules": 1968, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 337, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 282, - "apps/sim/lib/auth/index.ts": 210, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, - "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 100 + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 335, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 295, + "apps/sim/lib/auth/index.ts": 214, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 130, + "apps/sim/lib/api/contracts/index.ts": 108, + "apps/sim/lib/webhooks/providers/index.ts": 102 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -60,25 +60,25 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 2919, + "modules": 2959, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, - "apps/sim/lib/auth/index.ts": 204 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1346, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1000, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 865, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 862, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 312, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 304, + "apps/sim/lib/auth/index.ts": 208 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1284, + "modules": 1300, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1259, - "apps/sim/blocks/registry.ts": 935, - "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 127, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1273, + "apps/sim/blocks/registry.ts": 919, + "apps/sim/triggers/index.ts": 484, + "apps/sim/lib/api/contracts/index.ts": 128, "apps/sim/stores/workflows/registry/store.ts": 67, "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, @@ -86,11 +86,11 @@ } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1266, + "modules": 1283, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1265, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 339, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1282, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 342, "apps/sim/lib/api/contracts/index.ts": 134, "apps/sim/stores/workflows/registry/store.ts": 64, "apps/sim/hooks/queries/deployments.ts": 61, @@ -99,12 +99,12 @@ } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1269, + "modules": 1287, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 993, - "apps/sim/blocks/registry.ts": 936, - "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 129, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1007, + "apps/sim/blocks/registry.ts": 920, + "apps/sim/triggers/index.ts": 484, + "apps/sim/lib/api/contracts/index.ts": 130, "apps/sim/stores/workflows/registry/store.ts": 67, "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, @@ -112,68 +112,68 @@ } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1482, + "modules": 1501, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1203, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 330, - "apps/sim/blocks/registry-maps.ts": 327, - "apps/sim/lib/api/contracts/index.ts": 119, - "apps/sim/connectors/registry.ts": 62, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1218, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 338, + "apps/sim/blocks/registry-maps.ts": 335, + "apps/sim/lib/api/contracts/index.ts": 120, + "apps/sim/connectors/registry.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1483, + "modules": 1504, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1204, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 330, - "apps/sim/blocks/registry-maps.ts": 327, - "apps/sim/lib/api/contracts/index.ts": 119, - "apps/sim/connectors/registry.ts": 62, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1221, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 338, + "apps/sim/blocks/registry-maps.ts": 335, + "apps/sim/lib/api/contracts/index.ts": 120, + "apps/sim/connectors/registry.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2138, + "modules": 2177, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 325, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 333, "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 273, "apps/sim/lib/knowledge/application/knowledge-bases.ts": 218, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 168, - "apps/sim/lib/auth/index.ts": 158, - "apps/sim/lib/knowledge/orchestration/index.ts": 141, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 136 + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 182, + "apps/sim/lib/auth/index.ts": 163, + "apps/sim/lib/knowledge/orchestration/index.ts": 138, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 133 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 1974, + "modules": 2003, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 324, - "apps/sim/lib/auth/index.ts": 272, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 270, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 262, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 165, - "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 100 + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 332, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 279, + "apps/sim/lib/auth/index.ts": 278, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 271, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 176, + "apps/sim/lib/api/contracts/index.ts": 110, + "apps/sim/lib/webhooks/providers/index.ts": 102 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1711, + "modules": 1737, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1434, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 419, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 363, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 319, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 282, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 252 + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1456, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 421, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 365, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 321, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 286, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 255 } }, "app/workspace/[workspaceId]/page.tsx": { @@ -185,16 +185,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2003, + "modules": 2039, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 420, - "apps/sim/blocks/registry.ts": 325, - "apps/sim/lib/auth/index.ts": 282, - "apps/sim/lib/api/contracts/index.ts": 105, - "apps/sim/lib/webhooks/providers/index.ts": 100, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 434, + "apps/sim/blocks/registry.ts": 328, + "apps/sim/lib/auth/index.ts": 288, + "apps/sim/lib/api/contracts/index.ts": 107, + "apps/sim/lib/webhooks/providers/index.ts": 102, "apps/sim/lib/api/contracts/tools/index.ts": 59, - "apps/sim/lib/workflows/lifecycle.ts": 48 + "apps/sim/lib/uploads/utils/file-utils.server.ts": 42 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -202,16 +202,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1588, + "modules": 1610, "gateways": { - "apps/sim/lib/auth/index.ts": 1455, - "apps/sim/triggers/index.ts": 447, - "apps/sim/blocks/registry.ts": 339, - "apps/sim/blocks/registry-maps.ts": 336, - "apps/sim/lib/api/contracts/index.ts": 122, - "apps/sim/lib/webhooks/providers/index.ts": 100, - "apps/sim/stores/workflows/registry/store.ts": 64, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/lib/auth/index.ts": 1475, + "apps/sim/triggers/index.ts": 449, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/blocks/registry-maps.ts": 339, + "apps/sim/lib/api/contracts/index.ts": 123, + "apps/sim/lib/webhooks/providers/index.ts": 102, + "apps/sim/stores/workflows/registry/store.ts": 68, + "apps/sim/hooks/queries/deployments.ts": 62 } }, "app/workspace/[workspaceId]/settings/layout.tsx": { @@ -223,159 +223,159 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1297, + "modules": 1317, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 1296, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1005, - "apps/sim/components/permissions/index.ts": 992, - "apps/sim/components/permissions/add-people-modal.tsx": 983, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 981, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 340 + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 135, + "apps/sim/stores/workflows/registry/store.ts": 64, + "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 63, + "apps/sim/hooks/queries/deployments.ts": 61, + "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1388, + "modules": 1402, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1387, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 125, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1401, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 126, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1386, + "modules": 1400, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1385, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 125, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1399, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 126, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1250, + "modules": 1268, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 974, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 962, - "apps/sim/blocks/registry.ts": 950, - "apps/sim/blocks/registry-maps.ts": 948, - "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 134, - "apps/sim/stores/workflows/registry/store.ts": 68, - "apps/sim/hooks/queries/deployments.ts": 62 + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 988, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 976, + "apps/sim/blocks/registry.ts": 964, + "apps/sim/blocks/registry-maps.ts": 962, + "apps/sim/triggers/index.ts": 484, + "apps/sim/lib/api/contracts/index.ts": 135, + "apps/sim/stores/workflows/registry/store.ts": 74, + "apps/sim/hooks/queries/deployments.ts": 68 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2204, + "modules": 2236, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 577, - "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 331, - "apps/sim/blocks/registry.ts": 309, - "apps/sim/lib/auth/index.ts": 303, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 289, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 261, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 232 + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 584, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 335, + "apps/sim/blocks/registry.ts": 312, + "apps/sim/lib/auth/index.ts": 309, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 293, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 265, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 235 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1788, + "modules": 1827, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 336, - "apps/sim/lib/auth/index.ts": 293, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 126, - "apps/sim/lib/api/contracts/index.ts": 111, - "apps/sim/lib/webhooks/providers/index.ts": 100, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 334, + "apps/sim/lib/auth/index.ts": 299, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 140, + "apps/sim/lib/api/contracts/index.ts": 112, + "apps/sim/lib/webhooks/providers/index.ts": 102, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 50 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 53 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 267, + "modules": 268, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 260, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 213, - "apps/sim/lib/billing/client/upgrade.ts": 205, - "apps/sim/hooks/queries/organization.ts": 201, - "apps/sim/hooks/queries/workspace.ts": 193, - "apps/sim/lib/api/contracts/index.ts": 191, + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 261, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 214, + "apps/sim/lib/billing/client/upgrade.ts": 206, + "apps/sim/hooks/queries/organization.ts": 202, + "apps/sim/hooks/queries/workspace.ts": 194, + "apps/sim/lib/api/contracts/index.ts": 192, "apps/sim/lib/api/contracts/tools/index.ts": 61, "apps/sim/lib/api/contracts/v1/index.ts": 38 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2160, + "modules": 2187, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2159, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 540, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2186, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 545, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 462, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 138 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2187, + "modules": 2214, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2186, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 305, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 267, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 224, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 140, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 133 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2213, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 308, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 270, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 227, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 136 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2160, + "modules": 2187, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 904, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 129 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 922, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 462, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 139, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 138 } }, "app/workspace/layout.tsx": { - "modules": 1208, + "modules": 1219, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1198, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 138, - "apps/sim/stores/workflows/registry/store.ts": 65, - "apps/sim/hooks/queries/deployments.ts": 62, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/app/workspace/providers/socket-provider.tsx": 1209, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 139, + "apps/sim/stores/workflows/registry/store.ts": 67, + "apps/sim/hooks/queries/deployments.ts": 64, + "apps/sim/lib/workflows/comparison/compare.ts": 61 } }, "app/workspace/page.tsx": { - "modules": 1202, + "modules": 1213, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 971, - "apps/sim/triggers/index.ts": 482, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 137, - "apps/sim/stores/workflows/registry/store.ts": 64, - "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 58 + "apps/sim/lib/auth/stale-session-recovery.ts": 981, + "apps/sim/triggers/index.ts": 484, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 138, + "apps/sim/stores/workflows/registry/store.ts": 66, + "apps/sim/hooks/queries/deployments.ts": 60, + "apps/sim/lib/api/contracts/tools/index.ts": 60 } } } diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index 252cdcce25c..028f7466c7e 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' import { defineRouteContract } from '../../apps/sim/lib/api/contracts/types' +import { + V2_ERROR_STATUS_BY_CODE, + type V2ErrorCode, +} from '../../apps/sim/lib/api/contracts/v2/error-codes' import { billingOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/billing' import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/files-audit' import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows' @@ -53,6 +57,24 @@ function operation( } } +/** A minimal route, for assertions about document-level output rather than the route itself. */ +function simpleRoute(): OpenApiRouteDefinition { + const response = z.object({ ok: z.boolean().describe('Whether the call succeeded.') }).meta({ + id: 'SimpleResponse', + title: 'Simple response', + description: 'Response body.', + }) + return defineOpenApiRoute( + defineRouteContract({ + method: 'GET', + path: '/simple', + response: { mode: 'json', schema: response }, + }), + operation('simple', { description: 'Simple.' }), + { response } + ) +} + function document(routes: readonly OpenApiRouteDefinition[]) { return defineOpenApiDocument({ output: 'unused.json', @@ -75,8 +97,22 @@ function document(routes: readonly OpenApiRouteDefinition[]) { headers: { Location: { schema: LOCATION_HEADER_SCHEMA } }, errorSchema: ERROR_SCHEMA, errorResponses: { - Unauthorized: { status: 401, description: 'Unauthorized.' }, - RateLimited: { status: 429, description: 'Rate limited.' }, + Unauthorized: { + status: 401, + description: 'Unauthorized.', + example: { error: { code: 'UNAUTHORIZED', message: 'API key required' } }, + }, + RateLimited: { + status: 429, + description: 'Rate limited.', + example: { error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' } }, + }, + /** Declared but referenced by no operation below, so it must not be published. */ + NotFound: { + status: 404, + description: 'Not found.', + example: { error: { code: 'NOT_FOUND', message: 'Not found' } }, + }, }, routes, }) @@ -635,4 +671,69 @@ describe('OpenAPI generator', () => { 'Content-Length': { $ref: '#/components/headers/Content-Length' }, }) }) + + it('gives each error response its own example beside the shared schema ref', () => { + const spec = generateOpenApiDocument(document([simpleRoute()])) + const responses = (spec.components as JsonObject).responses as JsonObject + const contentFor = (id: string) => + ((responses[id] as JsonObject).content as JsonObject)['application/json'] as JsonObject + + expect(contentFor('Unauthorized').schema).toEqual({ $ref: '#/components/schemas/TestError' }) + expect(contentFor('RateLimited').schema).toEqual({ $ref: '#/components/schemas/TestError' }) + expect(contentFor('Unauthorized').example).toEqual({ + error: { code: 'UNAUTHORIZED', message: 'API key required' }, + }) + expect(contentFor('RateLimited').example).toEqual({ + error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' }, + }) + }) + + it('rejects an error example that does not fit the error schema', () => { + expect(() => + generateOpenApiDocument({ + ...document([simpleRoute()]), + errorResponses: { + Unauthorized: { + status: 401, + description: 'Unauthorized.', + example: { error: { code: 'UNAUTHORIZED' } }, + }, + RateLimited: { + status: 429, + description: 'Rate limited.', + example: { error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' } }, + }, + }, + }) + ).toThrow(/Unauthorized example/) + }) + + it('publishes only the error responses its operations reference', () => { + const spec = generateOpenApiDocument(document([simpleRoute()])) + const responses = (spec.components as JsonObject).responses as JsonObject + + /** `NotFound` is defined on the document but no operation declares it. */ + expect(Object.keys(responses).sort()).toEqual(['RateLimited', 'Unauthorized']) + }) + + it('publishes a distinct example under every documented error status', () => { + const spec = generateOpenApiDocument(workflowsOpenApiDocument) + const responses = (spec.components as JsonObject).responses as JsonObject + const byStatus = new Map>() + + for (const response of Object.values(responses) as JsonObject[]) { + const content = (response.content as JsonObject)['application/json'] as JsonObject + const example = content.example as { error: { code: string } } + const status = V2_ERROR_STATUS_BY_CODE[example.error.code as V2ErrorCode] + expect(status, `${example.error.code} is not a v2 error code`).toBeDefined() + const codes = byStatus.get(status) ?? new Set() + codes.add(example.error.code) + byStatus.set(status, codes) + } + + /** Every status documents exactly one code — the property the derivation relies on. */ + for (const [status, codes] of byStatus) { + expect([...codes], `status ${status}`).toHaveLength(1) + } + }) }) diff --git a/scripts/openapi/generator.ts b/scripts/openapi/generator.ts index 16968910b54..cc8cd84c0f2 100644 --- a/scripts/openapi/generator.ts +++ b/scripts/openapi/generator.ts @@ -692,7 +692,20 @@ function validateSecurity( } } -function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObject): JsonObject { +/** + * The error responses this document's operations actually reference, as `components.responses`. + * + * Only the referenced ones are built. An error response carries a worked example, and the + * message in one is often the domain's rather than the surface's — a `423` reads `Workflow is + * locked` in one document and `This table is insert-locked` in another. Emitting the whole set + * everywhere would ship each document a body for a status it never answers, phrased by a + * domain it does not contain. + */ +function errorComponents( + definition: OpenApiDocumentDefinition, + schemas: JsonObject, + referencedErrors: ReadonlySet +): JsonObject { const generated = generateSchema( definition.errorSchema, 'output', @@ -707,6 +720,7 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj ) const responses: JsonObject = {} for (const [id, response] of Object.entries(definition.errorResponses)) { + if (!referencedErrors.has(id)) continue nonEmpty(id, 'Error response id') nonEmpty(response.description, `${id} description`) invariant( @@ -718,6 +732,11 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj for (const header of response.headers ?? []) { invariant(definition.headers[header], `${id} references unknown response header ${header}`) } + /** + * Held to the same standard as every other documented example: it must parse against + * the error schema, so a documented body cannot describe a shape the API never sends. + */ + validateExamples(definition.errorSchema, [response.example], 'output', `${id} example`) responses[id] = { description: response.description, ...(referencedHeaders(response.headers) @@ -726,6 +745,13 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj content: { 'application/json': { schema: { $ref: `#/components/schemas/${generated.name}` }, + /** + * Sits beside the `$ref` rather than on the shared schema: one schema serves every + * status, so a schema-level example is necessarily one status's body shown under + * all of them. A Media Type Object example overrides the schema's, which is what + * makes each status tab show its own. + */ + example: response.example, }, }, } @@ -742,7 +768,10 @@ export function generateOpenApiDocument(definition: OpenApiDocumentDefinition): validateSecurity(definition.security, definition, 'OpenAPI document') const schemas: JsonObject = {} - const responses = errorComponents(definition, schemas) + const referencedErrors = new Set( + definition.routes.flatMap((route) => [...route.operation.errors]) + ) + const responses = errorComponents(definition, schemas, referencedErrors) const paths: JsonObject = {} const operationIds = new Set() const routeKeys = new Set()