diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 81dbe58d948..cd326d6220a 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -3126,6 +3126,36 @@ export function AzureIcon(props: SVGProps) { ) } +export function AzureDevOpsIcon(props: SVGProps) { + const id = useId() + const gradientId = `azure_devops_gradient_${id}` + return ( + + + + + + + + + + + + + ) +} + export const GroqIcon = (props: SVGProps) => ( ) { ) } +export function RailwayIcon(props: SVGProps) { + return ( + + + + ) +} + export function BigQueryIcon(props: SVGProps) { return ( @@ -6950,3 +6988,29 @@ export function SnowflakeIcon(props: SVGProps) { ) } + +export function NewRelicIcon(props: SVGProps) { + return ( + + + + + ) +} + +export function WizaIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index dd5ab47b8cb..3f27a66e0c9 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -20,6 +20,7 @@ import { AshbyIcon, AthenaIcon, AttioIcon, + AzureDevOpsIcon, AzureIcon, BoxCompanyIcon, BrainIcon, @@ -127,6 +128,7 @@ import { MongoDBIcon, MySQLIcon, Neo4jIcon, + NewRelicIcon, NotionIcon, ObsidianIcon, OktaIcon, @@ -148,6 +150,7 @@ import { PulseIcon, QdrantIcon, QuiverIcon, + RailwayIcon, RDSIcon, RedditIcon, RedisIcon, @@ -197,6 +200,7 @@ import { WebflowIcon, WhatsAppIcon, WikipediaIcon, + WizaIcon, WordpressIcon, WorkdayIcon, xIcon, @@ -225,6 +229,7 @@ export const blockTypeToIconMap: Record = { ashby: AshbyIcon, athena: AthenaIcon, attio: AttioIcon, + azure_devops: AzureDevOpsIcon, box: BoxCompanyIcon, brandfetch: BrandfetchIcon, brightdata: BrightDataIcon, @@ -263,6 +268,8 @@ export const blockTypeToIconMap: Record = { extend_v2: ExtendIcon, fathom: FathomIcon, file: DocumentIcon, + file_v2: DocumentIcon, + file_v3: DocumentIcon, file_v4: DocumentIcon, findymail: FindymailIcon, firecrawl: FirecrawlIcon, @@ -308,6 +315,7 @@ export const blockTypeToIconMap: Record = { iam: IAMIcon, identity_center: IdentityCenterIcon, image_generator: ImageIcon, + image_generator_v2: ImageIcon, imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, @@ -340,11 +348,13 @@ export const blockTypeToIconMap: Record = { microsoft_planner: MicrosoftPlannerIcon, microsoft_teams: MicrosoftTeamsIcon, mistral_parse: MistralIcon, + mistral_parse_v2: MistralIcon, mistral_parse_v3: MistralIcon, monday: MondayIcon, mongodb: MongoDBIcon, mysql: MySQLIcon, neo4j: Neo4jIcon, + new_relic: NewRelicIcon, notion: NotionIcon, notion_v2: NotionIcon, obsidian: ObsidianIcon, @@ -368,6 +378,7 @@ export const blockTypeToIconMap: Record = { pulse_v2: PulseIcon, qdrant: QdrantIcon, quiver: QuiverIcon, + railway: RailwayIcon, rds: RDSIcon, reddit: RedditIcon, redis: RedisIcon, @@ -420,12 +431,14 @@ export const blockTypeToIconMap: Record = { vercel: VercelIcon, video_generator: VideoIcon, video_generator_v2: VideoIcon, + video_generator_v3: VideoIcon, vision: EyeIcon, vision_v2: EyeIcon, wealthbox: WealthboxIcon, webflow: WebflowIcon, whatsapp: WhatsAppIcon, wikipedia: WikipediaIcon, + wiza: WizaIcon, wordpress: WordpressIcon, workday: WorkdayIcon, x: xIcon, diff --git a/apps/docs/content/docs/en/blocks/function.mdx b/apps/docs/content/docs/en/blocks/function.mdx index 4f5037be127..e8c0bc5b7bd 100644 --- a/apps/docs/content/docs/en/blocks/function.mdx +++ b/apps/docs/content/docs/en/blocks/function.mdx @@ -198,7 +198,7 @@ const file = ; const base64 = await sim.files.readBase64(file); ``` -`sim.files.readBase64(file)`, `sim.files.readText(file)`, `sim.files.readBase64Chunk(file, { offset, length })`, and `sim.files.readTextChunk(file, { offset, length })` read from server-side execution storage under memory caps. `sim.values.read(ref)` can explicitly read a large execution value reference. These helpers are available only in JavaScript functions without imports. JavaScript with imports, Python, and shell do not support these lazy helpers yet. +`sim.files.readBase64(file)`, `sim.files.readText(file)`, `sim.files.readBase64Chunk(file, { offset, length })`, and `sim.files.readTextChunk(file, { offset, length })` read from server-side execution storage under memory caps. `sim.values.read(ref)` explicitly reads a large execution value reference, and `sim.values.readArray(ref)` reads a manifest-backed large array. These helpers are available only in JavaScript functions without imports. JavaScript with imports, Python, and shell do not support these lazy helpers yet. Very large full reads can still fail by design; use chunk helpers or return a file when you need to handle more data. @@ -228,7 +228,7 @@ return { name: file.name, chunk: firstMegabyteBase64 }; Chunk `offset` and `length` are byte-based. For Unicode text, a chunk can split a multi-byte character at the boundary; use text chunks for approximate text processing and prefer smaller structured references when exact parsing matters. -Avoid passing a full large object into a Function block when you only need one field. For example, prefer `` over `` when the API response is large. If a JavaScript Function without imports references a large execution value, Sim automatically reads it through `sim.values.read(...)` at runtime under memory caps. +Avoid passing a full large object into a Function block when you only need one field. For example, prefer `` over `` when the API response is large. If a JavaScript Function without imports references a whole large execution value, Sim automatically rewrites it to `sim.values.read(...)` at runtime under memory caps. If the value is a manifest-backed array, Sim rewrites it to `sim.values.readArray(...)` so array variables can stay compact between blocks. For large generated data, write the result to a file or table with `outputPath`, `outputSandboxPath`, or `outputTable` instead of returning the entire payload inline. diff --git a/apps/docs/content/docs/en/execution/api-deployment.mdx b/apps/docs/content/docs/en/execution/api-deployment.mdx index b7f1de3fbf9..8bcc9094e87 100644 --- a/apps/docs/content/docs/en/execution/api-deployment.mdx +++ b/apps/docs/content/docs/en/execution/api-deployment.mdx @@ -232,7 +232,7 @@ Workflow execution responses are capped by platform request and response limits. } ``` -The `version` field is part of the external API contract. Treat the reference as an opaque placeholder for a value that could not be safely embedded in the response. `id`, `key`, and `executionId` are not fetch URLs; `key` points to execution-scoped server storage. Use `selectedOutputs` to request a smaller nested field, reduce the data passed between blocks, or return the data from a Response block when your workflow intentionally owns the HTTP response body. File outputs are metadata-first; request `.base64` only when you need inline file content. JavaScript Function blocks can explicitly read large files or value refs with the `sim.files` and `sim.values` helpers under memory caps. +The `version` field is part of the external API contract. Treat the reference as an opaque placeholder for a value that could not be safely embedded in the response. `id`, `key`, and `executionId` are not fetch URLs; `key` points to execution-scoped server storage. Use `selectedOutputs` to request a smaller nested field, reduce the data passed between blocks, or return the data from a Response block when your workflow intentionally owns the HTTP response body. File outputs are metadata-first; request `.base64` only when you need inline file content. JavaScript Function blocks can explicitly read large files, value refs, and manifest-backed arrays with the `sim.files` and `sim.values` helpers under memory caps. ### Asynchronous diff --git a/apps/docs/content/docs/en/tools/azure_devops.mdx b/apps/docs/content/docs/en/tools/azure_devops.mdx new file mode 100644 index 00000000000..38b3bfb69d8 --- /dev/null +++ b/apps/docs/content/docs/en/tools/azure_devops.mdx @@ -0,0 +1,554 @@ +--- +title: Azure DevOps +description: Interact with Azure DevOps pipelines, builds, and work items +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Azure DevOps](https://azure.microsoft.com/en-us/products/devops) is Microsoft's end-to-end DevOps platform for planning, building, testing, and shipping software. It powers engineering at tens of thousands of enterprises across automotive, financial services, government, and any organization built on the Microsoft stack. + +With the Azure DevOps integration in Sim, you can: + +- **Inspect pipelines and runs**: List pipelines, fetch metadata, and walk through run history with status and result +- **Triage build failures**: Pull build timelines to see which stage, job, or task failed, then fetch the exact log for the failing step +- **Audit changes between builds**: Surface the work items that landed between any two builds — useful for release notes and regression hunts +- **Query work items with WIQL**: Run full WIQL queries and get hydrated work item fields back in a single call, not just IDs +- **Manage work item lifecycle**: Create, update, and read Issues, Tasks, and Epics with structured fields — title, description, priority, assignee, area path, iteration, tags, effort, and dates +- **Collaborate via comments**: Add internal or public comments to work items and read full comment history +- **React in real time**: Trigger workflows when builds fail or new work items are created via Azure DevOps service hooks + +These capabilities let your Sim agents close the loop on the DevOps lifecycle — automatically triaging broken builds, drafting release notes between deployments, syncing work items across systems, and keeping engineering operations running while your team focuses on shipping. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Azure DevOps into your workflow. List and inspect pipelines and builds, query and manage work items, and add or read comments. + + + +## Tools + +### `azure_devops_list_pipelines` + +List all pipelines in an Azure DevOps project. Returns pipeline ID, name, folder, revision, and URL. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `orderBy` | string | No | Field to sort results by \(e.g. "name"\) | +| `top` | number | No | Maximum number of pipelines to return | +| `continuationToken` | string | No | Continuation token for paginating results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of pipelines | +| `metadata` | object | Pipelines metadata | +| ↳ `count` | number | Total number of pipelines returned | +| ↳ `pipelines` | array | Array of pipeline objects | +| ↳ `id` | number | Pipeline ID | +| ↳ `name` | string | Pipeline name | +| ↳ `folder` | string | Folder path \(e.g. "\\\\"\) | +| ↳ `revision` | number | Pipeline revision number | +| ↳ `url` | string | Pipeline API URL | + +### `azure_devops_get_pipeline` + +Get details for a specific pipeline in an Azure DevOps project, including configuration and repository info. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `pipelineId` | number | Yes | ID of the pipeline to retrieve | +| `pipelineVersion` | number | No | Specific revision of the pipeline to retrieve \(defaults to latest\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the pipeline | +| `metadata` | object | Pipeline detail metadata | +| ↳ `pipeline` | object | Full pipeline detail object | +| ↳ `id` | number | Pipeline ID | +| ↳ `name` | string | Pipeline name | +| ↳ `folder` | string | Folder path | +| ↳ `revision` | number | Pipeline revision number | +| ↳ `url` | string | Pipeline API URL | +| ↳ `configuration` | object | Pipeline configuration | +| ↳ `type` | string | Configuration type \(e.g. "yaml"\) | +| ↳ `path` | string | YAML file path in the repository | +| ↳ `repository` | object | Source repository info | +| ↳ `id` | string | Repository ID | +| ↳ `type` | string | Repository type \(e.g. "azureReposGit"\) | +| ↳ `links` | object | Hypermedia links | +| ↳ `self` | string | API self-link | +| ↳ `web` | string | Browser URL for the pipeline | + +### `azure_devops_list_pipeline_runs` + +List runs for a specific pipeline in an Azure DevOps project. Returns run ID, name, state, result, and timestamps. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `pipelineId` | number | Yes | ID of the pipeline whose runs to list | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of pipeline runs | +| `metadata` | object | Pipeline runs metadata | +| ↳ `count` | number | Total number of runs returned | +| ↳ `runs` | array | Array of pipeline run objects | +| ↳ `id` | number | Run ID | +| ↳ `name` | string | Run name \(e.g. "20210601.1"\) | +| ↳ `state` | string | Run state \(e.g. "completed", "inProgress"\) | +| ↳ `result` | string | Run result \(e.g. "succeeded", "failed"\) — absent if still running | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `finishedDate` | string | ISO 8601 finish timestamp — absent if still running | +| ↳ `url` | string | Run API URL | +| ↳ `webUrl` | string | Browser URL for the run | + +### `azure_devops_get_pipeline_run` + +Get details for a specific pipeline run in an Azure DevOps project. Returns run state, result, timestamps, and the pipeline reference. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `pipelineId` | number | Yes | ID of the pipeline | +| `runId` | number | Yes | ID of the run to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the pipeline run | +| `metadata` | object | Pipeline run metadata | +| ↳ `run` | object | Full pipeline run detail object | +| ↳ `id` | number | Run ID | +| ↳ `name` | string | Run name \(e.g. "20210601.1"\) | +| ↳ `state` | string | Run state \(e.g. "completed", "inProgress"\) | +| ↳ `result` | string | Run result \(e.g. "succeeded", "failed"\) — absent if still running | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `finishedDate` | string | ISO 8601 finish timestamp — absent if still running | +| ↳ `url` | string | Run API URL | +| ↳ `webUrl` | string | Browser URL for the run | +| ↳ `pipeline` | object | Pipeline reference | +| ↳ `id` | number | Pipeline ID | +| ↳ `name` | string | Pipeline name | +| ↳ `folder` | string | Pipeline folder | +| ↳ `revision` | number | Pipeline revision number | +| ↳ `url` | string | Pipeline API URL | + +### `azure_devops_list_builds` + +List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, or branch. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `definitionIds` | string | No | Comma-separated pipeline definition IDs to filter by \(e.g. "1,2,3"\) | +| `top` | number | No | Maximum number of builds to return | +| `statusFilter` | string | No | Filter by build status: inProgress, completed, cancelling, postponed, notStarted, none | +| `resultFilter` | string | No | Filter by build result: succeeded, partiallySucceeded, failed, canceled | +| `branchName` | string | No | Filter by source branch name \(e.g. "refs/heads/main"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of builds | +| `metadata` | object | Builds metadata | +| ↳ `count` | number | Total number of builds returned | +| ↳ `builds` | array | Array of build objects | +| ↳ `id` | number | Build ID | +| ↳ `buildNumber` | string | Build number \(e.g. "20210601.1"\) | +| ↳ `status` | string | Build status \(e.g. "completed", "inProgress"\) | +| ↳ `result` | string | Build result \(e.g. "succeeded", "failed"\) — absent if still running | +| ↳ `queueTime` | string | ISO 8601 queue timestamp | +| ↳ `startTime` | string | ISO 8601 start timestamp | +| ↳ `finishTime` | string | ISO 8601 finish timestamp — absent if still running | +| ↳ `sourceBranch` | string | Source branch \(e.g. "refs/heads/main"\) | +| ↳ `sourceVersion` | string | Source commit SHA | +| ↳ `definition` | object | Pipeline definition reference | +| ↳ `id` | number | Definition ID | +| ↳ `name` | string | Definition name | +| ↳ `webUrl` | string | Browser URL for the build | + +### `azure_devops_list_build_logs` + +List all log entries for a specific build in Azure DevOps. Returns log IDs, types, and line counts — use the log ID with the Get Build Log tool to fetch actual log text. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `buildId` | number | Yes | The build ID whose logs to list | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of build logs | +| `metadata` | object | Build logs metadata | +| ↳ `count` | number | Total number of log entries returned | +| ↳ `logs` | array | Array of log entry objects | +| ↳ `id` | number | Log entry ID — use with Get Build Log to fetch content | +| ↳ `type` | string | Log type \(e.g. "Container", "Task", "Section"\) | +| ↳ `url` | string | API URL for the log entry | +| ↳ `lineCount` | number | Number of lines in the log | +| ↳ `createdOn` | string | ISO 8601 creation timestamp | +| ↳ `lastChangedOn` | string | ISO 8601 last-changed timestamp | + +### `azure_devops_get_build_log` + +Fetch the text content of a specific build log in Azure DevOps. Use List Build Logs first to get the log ID. Optionally retrieve only a line range with startLine/endLine. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `buildId` | number | Yes | The build ID containing the log | +| `logId` | number | Yes | The log entry ID to fetch \(from List Build Logs\) | +| `startLine` | number | No | First line to return \(1-based, inclusive\) | +| `endLine` | number | No | Last line to return \(1-based, inclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Raw log text | +| `metadata` | object | Log metadata | +| ↳ `lineCount` | number | Number of lines in the returned log text | + +### `azure_devops_get_build_timeline` + +Get the execution timeline for an Azure DevOps build — every stage, job, and task with its result and log ID. Use this to identify which steps failed before fetching their logs with Get Build Log. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `buildId` | number | Yes | ID of the build whose timeline to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Summary of the build timeline, highlighting failed steps | +| `metadata` | object | Build timeline metadata | +| ↳ `totalCount` | number | Total number of timeline records | +| ↳ `failedCount` | number | Number of failed records | +| ↳ `records` | array | All timeline records \(stages, jobs, tasks\) | +| ↳ `id` | string | Record GUID | +| ↳ `name` | string | Step name \(e.g. "Run tests"\) | +| ↳ `type` | string | Stage \| Phase \| Job \| Task | +| ↳ `result` | string | succeeded \| failed \| skipped \| canceled \| null | +| ↳ `logId` | number | Log ID to pass to Get Build Log, or null | +| ↳ `errorCount` | number | Number of errors | +| ↳ `warningCount` | number | Number of warnings | +| ↳ `startTime` | string | ISO 8601 start timestamp | +| ↳ `finishTime` | string | ISO 8601 finish timestamp | +| ↳ `failedRecords` | array | Subset of records where result === "failed" — use logId to fetch logs | +| ↳ `id` | string | Record GUID | +| ↳ `name` | string | Step name | +| ↳ `type` | string | Stage \| Phase \| Job \| Task | +| ↳ `result` | string | failed | +| ↳ `logId` | number | Log ID to pass to Get Build Log | +| ↳ `errorCount` | number | Number of errors | +| ↳ `warningCount` | number | Number of warnings | +| ↳ `startTime` | string | ISO 8601 start timestamp | +| ↳ `finishTime` | string | ISO 8601 finish timestamp | + +### `azure_devops_get_work_items_between_builds` + +Get work item references associated with commits between two builds in Azure DevOps. Returns work item IDs and URLs — use Get Work Items Batch for full field details. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `fromBuildId` | number | Yes | The older build ID \(start of range\) | +| `toBuildId` | number | Yes | The newer build ID \(end of range\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of work items between builds | +| `metadata` | object | Work items metadata | +| ↳ `count` | number | Total number of work item references returned | +| ↳ `workItems` | array | Array of work item references | +| ↳ `id` | string | Work item ID | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_query_work_items` + +Execute a WIQL query to search for work items in Azure DevOps and return full field details. Use TOP N in your query to limit results (Azure enforces a 200-item maximum per batch fetch). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `wiqlQuery` | string | Yes | WIQL query string \(e.g. "SELECT \[System.Id\] FROM workitems WHERE \[System.State\] = \'Doing\' ORDER BY \[System.Id\] ASC"\). Use TOP N to limit results. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of matching work items | +| `metadata` | object | Work items metadata | +| ↳ `count` | number | Number of work items returned | +| ↳ `workItems` | array | Array of work item details | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_get_work_item` + +Fetch full details of a single work item by ID from Azure DevOps, including title, state, type, assignee, and area path. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | The work item ID to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the work item | +| `metadata` | object | Work item metadata | +| ↳ `workItem` | object | Full work item details | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_get_work_items_batch` + +Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `ids` | string | Yes | Comma-separated work item IDs to fetch \(e.g. "123,456,789"\). Maximum 200 IDs. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the fetched work items | +| `metadata` | object | Work items metadata | +| ↳ `count` | number | Number of work items returned | +| ↳ `workItems` | array | Array of work item details | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_create_work_item` + +Create a new Basic-process work item (Issue, Task, or Epic) in Azure DevOps. Returns the created work item with its assigned ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemType` | string | Yes | Basic-process work item type to create \("Issue", "Task", or "Epic"\). Use Issue for bug or defect tracking. | +| `title` | string | Yes | Title of the new work item | +| `description` | string | No | HTML description of the work item \(optional\) | +| `assignedTo` | string | No | Email or display name of the user to assign the work item to \(optional\) | +| `priority` | number | No | Priority of the work item \(1 = Critical, 2 = High, 3 = Medium, 4 = Low\) | +| `effort` | number | No | Effort \(Microsoft.VSTS.Scheduling.Effort\). Basic process: Issue only. | +| `startDate` | string | No | Start date \(Microsoft.VSTS.Scheduling.StartDate\), ISO 8601. Basic process: Epic only. | +| `targetDate` | string | No | Target date \(Microsoft.VSTS.Scheduling.TargetDate\), ISO 8601. Basic process: Epic only. | +| `activity` | string | No | Activity \(Microsoft.VSTS.Common.Activity\). One of Deployment, Design, Development, Documentation, Requirements, Testing. Basic process: Task only. | +| `remainingWork` | number | No | Remaining work hours \(Microsoft.VSTS.Scheduling.RemainingWork\). Basic process: Task only. | +| `completedWork` | number | No | Completed work hours \(Microsoft.VSTS.Scheduling.CompletedWork\). Basic process: Task only. | +| `areaPath` | string | No | Area path for the work item, e.g. "MyProject\\\\Team" \(optional\) | +| `iterationPath` | string | No | Iteration path for the work item, e.g. "MyProject\\\\Sprint 1" \(optional\) | +| `tags` | string | No | Semicolon-separated tags, e.g. "issue; p1; auth" \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the created work item | +| `metadata` | object | Created work item metadata | +| ↳ `workItem` | object | Full details of the created work item | +| ↳ `id` | number | Assigned work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Initial state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the created work item | + +### `azure_devops_update_work_item` + +Update one or more fields on an existing work item in Azure DevOps. Provide only the fields you want to change. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | ID of the work item to update | +| `title` | string | No | New title for the work item \(optional\) | +| `description` | string | No | New HTML description for the work item \(optional\) | +| `assignedTo` | string | No | Email or display name to reassign the work item to \(optional\) | +| `areaPath` | string | No | New area path for the work item \(optional\) | +| `priority` | number | No | Priority of the work item \(1 = Critical, 2 = High, 3 = Medium, 4 = Low\) \(optional\) | +| `state` | string | No | New state for Basic-process work items: "To Do", "Doing", or "Done" \(optional\) | +| `effort` | number | No | Effort \(Microsoft.VSTS.Scheduling.Effort\). Basic process: Issue only. | +| `startDate` | string | No | Start date \(Microsoft.VSTS.Scheduling.StartDate\), ISO 8601. Basic process: Epic only. | +| `targetDate` | string | No | Target date \(Microsoft.VSTS.Scheduling.TargetDate\), ISO 8601. Basic process: Epic only. | +| `activity` | string | No | Activity \(Microsoft.VSTS.Common.Activity\). One of Deployment, Design, Development, Documentation, Requirements, Testing. Basic process: Task only. | +| `remainingWork` | number | No | Remaining work hours \(Microsoft.VSTS.Scheduling.RemainingWork\). Basic process: Task only. | +| `completedWork` | number | No | Completed work hours \(Microsoft.VSTS.Scheduling.CompletedWork\). Basic process: Task only. | +| `tags` | string | No | Semicolon-separated tags to set on the work item \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the updated work item | +| `metadata` | object | Updated work item metadata | +| ↳ `workItem` | object | Full details of the updated work item | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state after update | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_add_comment` + +Add a comment to a work item in Azure DevOps. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | ID of the work item to comment on | +| `text` | string | Yes | Comment text \(HTML supported, e.g. "<p>My comment</p>"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable confirmation of the added comment | +| `metadata` | object | Added comment metadata | +| ↳ `comment` | object | Full details of the created comment | +| ↳ `workItemId` | number | Work item the comment belongs to | +| ↳ `commentId` | number | Comment ID | +| ↳ `version` | number | Comment version | +| ↳ `text` | string | Comment text | +| ↳ `renderedText` | string | Rendered HTML comment text when available | +| ↳ `createdBy` | string | Display name of the comment author, or null | +| ↳ `createdDate` | string | ISO timestamp when comment was created | +| ↳ `modifiedBy` | string | Display name of the last modifier, or null | +| ↳ `modifiedDate` | string | ISO timestamp when comment was modified | +| ↳ `isDeleted` | boolean | Whether the comment is deleted | +| ↳ `url` | string | API URL for the comment | + +### `azure_devops_get_comments` + +List comments for an Azure DevOps work item. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | ID of the work item whose comments should be listed | +| `top` | number | No | Maximum number of comments to return | +| `continuationToken` | string | No | Continuation token for paginating comments | +| `includeDeleted` | boolean | No | Whether deleted comments should be returned | +| `expand` | string | No | Additional comment data to include: none, reactions, renderedText, renderedTextOnly, all | +| `order` | string | No | Sort order for comments: asc or desc | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of work item comments | +| `metadata` | object | Comments metadata | +| ↳ `count` | number | Number of comments returned in this page | +| ↳ `totalCount` | number | Total number of comments on the work item | +| ↳ `continuationToken` | string | Continuation token for the next page | +| ↳ `nextPage` | string | API URL for the next page | +| ↳ `url` | string | API URL for this comments list | +| ↳ `comments` | array | Array of work item comments | +| ↳ `workItemId` | number | Work item ID | +| ↳ `commentId` | number | Comment ID | +| ↳ `version` | number | Comment version | +| ↳ `text` | string | Comment text | +| ↳ `renderedText` | string | Rendered HTML comment text when available | +| ↳ `createdBy` | string | Display name of the comment author | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `modifiedBy` | string | Display name of the last modifier | +| ↳ `modifiedDate` | string | ISO 8601 modified timestamp | +| ↳ `isDeleted` | boolean | Whether the comment is deleted | +| ↳ `url` | string | API URL for the comment | + + diff --git a/apps/docs/content/docs/en/tools/gong.mdx b/apps/docs/content/docs/en/tools/gong.mdx index 954883120ad..f01b1ae395a 100644 --- a/apps/docs/content/docs/en/tools/gong.mdx +++ b/apps/docs/content/docs/en/tools/gong.mdx @@ -48,7 +48,7 @@ Retrieve call data by date range from Gong. | `accessKey` | string | Yes | Gong API Access Key | | `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `fromDateTime` | string | Yes | Start date/time in ISO-8601 format \(e.g., 2024-01-01T00:00:00Z\) | -| `toDateTime` | string | No | End date/time in ISO-8601 format \(e.g., 2024-01-31T23:59:59Z\). If omitted, lists calls up to the most recent. | +| `toDateTime` | string | No | End date/time in ISO-8601 format \(e.g., 2024-01-31T23:59:59Z\). Defaults to the current execution time when omitted. | | `cursor` | string | No | Pagination cursor from a previous response | | `workspaceId` | string | No | Gong workspace ID to filter calls | @@ -56,6 +56,7 @@ Retrieve call data by date range from Gong. | Parameter | Type | Description | | --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | | `calls` | array | List of calls matching the date range | | ↳ `id` | string | Gong's unique numeric identifier for the call | | ↳ `title` | string | Call title | @@ -79,6 +80,39 @@ Retrieve call data by date range from Gong. | ↳ `calendarEventId` | string | Calendar event identifier | | `cursor` | string | Pagination cursor for the next page | | `totalRecords` | number | Total number of records matching the filter | +| `currentPageSize` | number | Number of records in the current page | +| `currentPageNumber` | number | Current page number | + +### `gong_create_call` + +Upload call metadata to Gong and let Gong pull the media from a URL. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `clientUniqueId` | string | Yes | Unique call ID from the source telephony or recording system | +| `actualStart` | string | Yes | Actual call start time in ISO-8601 format | +| `primaryUser` | string | Yes | Gong user ID for the call's host or owner | +| `parties` | json | Yes | Array of call parties, with at least the primary user included | +| `direction` | string | Yes | Call direction: Inbound, Outbound, Conference, or Unknown | +| `downloadMediaUrl` | string | Yes | URL where Gong can download the call media file | +| `title` | string | No | Human-readable call title | +| `workspaceId` | string | No | Optional Gong workspace ID | +| `disposition` | string | No | Optional call disposition | +| `purpose` | string | No | Optional call purpose | +| `context` | json | No | Optional CRM context array for the call | +| `callProviderCode` | string | No | Optional conferencing or telephony provider code | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `callId` | string | Gong's unique numeric identifier for the created call | +| `requestId` | string | Gong request reference ID for troubleshooting | +| `url` | string | URL to the created call in the Gong web app | ### `gong_get_call` @@ -275,6 +309,7 @@ List all users in your Gong account. | Parameter | Type | Description | | --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | | `users` | array | List of Gong users | | ↳ `id` | string | Unique numeric user ID \(up to 20 digits\) | | ↳ `emailAddress` | string | User email address | diff --git a/apps/docs/content/docs/en/tools/google_docs.mdx b/apps/docs/content/docs/en/tools/google_docs.mdx index 35dbd4eae8d..069c91f8f6c 100644 --- a/apps/docs/content/docs/en/tools/google_docs.mdx +++ b/apps/docs/content/docs/en/tools/google_docs.mdx @@ -56,7 +56,7 @@ Read content from a Google Docs document ### `google_docs_write` -Write or update content in a Google Docs document +Append content to a Google Docs document. Content is inserted literally; Markdown is not interpreted. For formatted output from Markdown, use the Create operation with the markdown toggle enabled. #### Input @@ -88,6 +88,7 @@ Create a new Google Docs document | `content` | string | No | The content of the document to create | | `folderSelector` | string | No | Google Drive folder ID to create the document in \(e.g., 1ABCxyz...\) | | `folderId` | string | No | The ID of the folder to create the document in \(internal use\) | +| `markdown` | boolean | No | When true, content is interpreted as Markdown and converted to formatted Google Docs content \(headings, bold/italic, lists, tables, links, code blocks, blockquotes\). Default: false \(content inserted as plain text\). | #### Output diff --git a/apps/docs/content/docs/en/tools/image_generator.mdx b/apps/docs/content/docs/en/tools/image_generator.mdx index 7e1f25bc642..f44eba6f1f6 100644 --- a/apps/docs/content/docs/en/tools/image_generator.mdx +++ b/apps/docs/content/docs/en/tools/image_generator.mdx @@ -6,63 +6,75 @@ description: Generate images import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} -[DALL-E](https://openai.com/dall-e-3) is OpenAI's advanced AI system designed to generate realistic images and art from natural language descriptions. As a state-of-the-art image generation model, DALL-E can create detailed and creative visuals based on text prompts, allowing users to transform their ideas into visual content without requiring artistic skills. +The Image Generator block creates images from text prompts using leading image generation providers. Choose OpenAI for GPT Image models, Google Gemini for Nano Banana models, or Fal.ai for a multi-model catalog that includes Nano Banana, GPT Image, Seedream, FLUX, and Grok Imagine. -With DALL-E, you can: +Use it to: -- **Generate realistic images**: Create photorealistic visuals from textual descriptions -- **Design conceptual art**: Transform abstract ideas into visual representations -- **Produce variations**: Generate multiple interpretations of the same prompt -- **Control artistic style**: Specify artistic styles, mediums, and visual aesthetics -- **Create detailed scenes**: Describe complex scenes with multiple elements and relationships -- **Visualize products**: Generate product mockups and design concepts -- **Illustrate ideas**: Turn written concepts into visual illustrations +- **Generate production images**: Create polished visuals from workflow prompts +- **Choose the right provider**: Route requests to OpenAI, Gemini, or Fal.ai based on model availability and cost +- **Control output shape**: Set provider-specific size, aspect ratio, resolution, quality, background, and output format options +- **Use advanced Fal.ai features**: Configure safety tolerance, safety checking, web search grounding, seeds, and thinking level when supported +- **Pass generated files downstream**: Use the returned image file or URL in later workflow steps -In Sim, the DALL-E integration enables your agents to generate images programmatically as part of their workflows. This allows for powerful automation scenarios such as content creation, visual design, and creative ideation. Your agents can formulate detailed prompts, generate corresponding images, and incorporate these visuals into their outputs or downstream processes. This integration bridges the gap between natural language processing and visual content creation, enabling your agents to communicate not just through text but also through compelling imagery. By connecting Sim with DALL-E, you can create agents that produce visual content on demand, illustrate concepts, generate design assets, and enhance user experiences with rich visual elements - all without requiring human intervention in the creative process. +In Sim, the Image Generator block lets agents create visual assets programmatically as part of automated workflows. This is useful for content creation, design mockups, product visuals, creative ideation, and any flow that needs generated imagery without a manual handoff. {/* MANUAL-CONTENT-END */} ## Usage Instructions -Integrate Image Generator into the workflow. Can generate images using DALL-E 3, GPT Image 1, or GPT Image 2. +Generate images using OpenAI GPT Image, Google Nano Banana, or Fal.ai image models. ## Tools -### `openai_image` +### `image_generate` -Generate images using OpenAI +Generate images with OpenAI GPT Image, Google Nano Banana, or Fal.ai image models #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `model` | string | Yes | The model to use \(dall-e-3, gpt-image-1, or gpt-image-2\) | -| `prompt` | string | Yes | A text description of the desired image | -| `size` | string | Yes | Image size. dall-e-3: 1024x1024, 1024x1792, or 1792x1024. gpt-image-1: auto, 1024x1024, 1536x1024, or 1024x1536. gpt-image-2: auto or any size with edges ≤3840px and multiples of 16 \(e.g. 1024x1024, 1536x1024, 1024x1536, 2560x1440, 3840x2160\). | -| `quality` | string | No | Quality. dall-e-3: standard\|hd. gpt-image-1/gpt-image-2: auto\|low\|medium\|high | -| `style` | string | No | The style of the image \(vivid or natural\), only for dall-e-3 | -| `background` | string | No | Background. gpt-image-1: auto\|transparent\|opaque. gpt-image-2: auto\|opaque \(transparent not supported\) | -| `outputFormat` | string | No | Output image format \(png, jpeg, webp\), only for gpt-image-1 and gpt-image-2 | -| `moderation` | string | No | Moderation level \(auto or low\), only for gpt-image-1 and gpt-image-2 | -| `n` | number | No | The number of images to generate \(1-10\) | -| `apiKey` | string | Yes | Your OpenAI API key | +| `provider` | string | Yes | Image generation provider: openai, gemini, or falai | +| `apiKey` | string | Yes | Provider API key | +| `model` | string | Yes | Provider model ID, such as gpt-image-1.5, gemini-3.1-flash-image-preview, or nano-banana-2 | +| `prompt` | string | Yes | Text prompt describing the image to generate | +| `size` | string | No | Provider-specific image size | +| `aspectRatio` | string | No | Aspect ratio, such as auto, 1:1, 16:9, or 9:16 | +| `resolution` | string | No | Provider-specific image resolution, such as 1K, 2K, 4K, 1k, or 2k | +| `quality` | string | No | Provider-specific image quality | +| `background` | string | No | Background setting when supported | +| `outputFormat` | string | No | Output image format: png, jpeg, or webp where supported | +| `moderation` | string | No | OpenAI moderation level: auto or low | +| `safetyTolerance` | string | No | Fal.ai safety tolerance when supported | +| `numImages` | number | No | Number of images to generate, subject to provider limits | +| `seed` | number | No | Random seed when supported | +| `enableSafetyChecker` | boolean | No | Enable the Fal.ai safety checker when supported | +| `enableWebSearch` | boolean | No | Enable web search grounding when supported by the selected Fal.ai model | +| `thinkingLevel` | string | No | Fal.ai thinking level when supported: minimal or high | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Operation success status | -| `output` | object | Generated image data | -| ↳ `content` | string | Image URL or identifier | -| ↳ `image` | string | Base64 encoded image data | -| ↳ `metadata` | object | Image generation metadata | -| ↳ `model` | string | Model used for image generation | +| `content` | string | Generated image URL or identifier | +| `image` | file | Generated image file | +| `imageUrl` | string | Generated image URL | +| `provider` | string | Provider used | +| `model` | string | Model used | +| `metadata` | json | Generation metadata | +| ↳ `provider` | string | Provider used | +| ↳ `model` | string | Model used | +| ↳ `description` | string | Provider description | +| ↳ `revisedPrompt` | string | Revised prompt | +| ↳ `seed` | number | Seed used for generation | +| ↳ `jobId` | string | Provider job ID | +| ↳ `contentType` | string | Image MIME type | diff --git a/apps/docs/content/docs/en/tools/incidentio.mdx b/apps/docs/content/docs/en/tools/incidentio.mdx index 3476d3535e7..5d10442a33b 100644 --- a/apps/docs/content/docs/en/tools/incidentio.mdx +++ b/apps/docs/content/docs/en/tools/incidentio.mdx @@ -51,6 +51,8 @@ List incidents from incident.io. Returns a list of incidents with their details | `apiKey` | string | Yes | incident.io API Key | | `page_size` | number | No | Number of incidents to return per page \(e.g., 10, 25, 50\). Default: 25 | | `after` | string | No | Pagination cursor to fetch the next page of results \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | +| `sort_by` | string | No | Sort order for incidents: created_at_newest_first or created_at_oldest_first | +| `filter_mode` | string | No | How to combine filters: all or any | #### Output @@ -234,6 +236,7 @@ List actions from incident.io. Optionally filter by incident ID. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | | `incident_id` | string | No | Filter actions by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | +| `incident_mode` | string | No | Filter actions by incident mode \(standard, retrospective, test, tutorial, or stream\) | #### Output @@ -308,6 +311,7 @@ List follow-ups from incident.io. Optionally filter by incident ID. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | | `incident_id` | string | No | Filter follow-ups by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | +| `incident_mode` | string | No | Filter follow-ups by incident mode \(standard, retrospective, test, tutorial, or stream\) | #### Output @@ -395,6 +399,8 @@ List all users in your Incident.io workspace. Returns user details including id, | `apiKey` | string | Yes | Incident.io API Key | | `page_size` | number | No | Number of results to return per page \(e.g., 10, 25, 50\). Default: 25 | | `after` | string | No | Pagination cursor to fetch the next page of results | +| `email` | string | No | Filter users by email address | +| `slack_user_id` | string | No | Filter users by Slack user ID | #### Output @@ -440,23 +446,78 @@ List all workflows in your incident.io workspace. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | -| `page_size` | number | No | Number of workflows to return per page \(e.g., 10, 25, 50\) | -| `after` | string | No | Pagination cursor to fetch the next page of results \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `workflows` | array | List of workflows | -| ↳ `id` | string | Unique identifier for the workflow | -| ↳ `name` | string | Name of the workflow | -| ↳ `state` | string | State of the workflow \(active, draft, or disabled\) | -| ↳ `folder` | string | Folder the workflow belongs to | -| ↳ `created_at` | string | When the workflow was created | -| ↳ `updated_at` | string | When the workflow was last updated | -| `pagination_meta` | object | Pagination metadata | -| ↳ `after` | string | Cursor for next page | -| ↳ `page_size` | number | Number of results per page | +| ↳ `id` | string | Workflow ID | +| ↳ `name` | string | Workflow name | +| ↳ `trigger` | string | Workflow trigger | +| ↳ `once_for` | array | Fields that make the workflow run once | +| ↳ `version` | number | Workflow version | +| ↳ `expressions` | array | Workflow expressions | +| ↳ `condition_groups` | array | Workflow condition groups | +| ↳ `steps` | array | Workflow steps | +| ↳ `include_private_incidents` | boolean | Whether the workflow includes private incidents | +| ↳ `include_private_escalations` | boolean | Whether the workflow includes private escalations | +| ↳ `runs_on_incident_modes` | array | Incident modes the workflow runs on | +| ↳ `continue_on_step_error` | boolean | Whether execution continues after a step error | +| ↳ `runs_on_incidents` | string | Incident lifecycle filter | +| ↳ `state` | string | Workflow state \(active, draft, disabled\) | +| ↳ `delay` | object | Workflow delay configuration | +| ↳ `folder` | string | Workflow folder | +| ↳ `runs_from` | string | When the workflow runs from | +| ↳ `shortform` | string | Workflow shortform identifier | + +### `incidentio_workflows_create` + +Create a new workflow in incident.io. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | incident.io API Key | +| `name` | string | Yes | Name of the workflow \(e.g., "Notify on Critical Incidents"\) | +| `folder` | string | No | Folder to organize the workflow in | +| `state` | string | No | State of the workflow \(active, draft, or disabled\) | +| `trigger` | string | No | Trigger type for the workflow \(e.g., "incident.updated", "incident.created"\) | +| `steps` | string | No | Array of workflow steps as JSON string. Example: \[\{"label": "Notify team", "name": "slack.post_message"\}\] | +| `condition_groups` | string | No | Array of condition groups as JSON string to control when the workflow runs. Example: \[\{"conditions": \[\{"operation": "one_of", "param_bindings": \[\], "subject": "incident.severity"\}\]\}\] | +| `runs_on_incidents` | string | No | When to run the workflow: "newly_created" \(only new incidents\), "newly_created_and_active" \(new and active incidents\), "active" \(only active incidents\), or "all" \(all incidents\) | +| `runs_on_incident_modes` | string | No | Array of incident modes to run on as JSON string. Example: \["standard", "retrospective"\] | +| `include_private_incidents` | boolean | No | Whether to include private incidents | +| `continue_on_step_error` | boolean | No | Whether to continue executing subsequent steps if a step fails | +| `once_for` | string | No | Array of fields to ensure the workflow runs only once per unique combination of these fields, as JSON string. Example: \["incident.id"\] | +| `expressions` | string | No | Array of workflow expressions as JSON string for advanced workflow logic. Example: \[\{"label": "My expression", "operations": \[\]\}\] | +| `delay` | string | No | Delay configuration as JSON string. Example: \{"for_seconds": 60, "conditions_apply_over_delay": false\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflow` | object | The created workflow | +| ↳ `id` | string | Workflow ID | +| ↳ `name` | string | Workflow name | +| ↳ `trigger` | string | Workflow trigger | +| ↳ `once_for` | array | Fields that make the workflow run once | +| ↳ `version` | number | Workflow version | +| ↳ `expressions` | array | Workflow expressions | +| ↳ `condition_groups` | array | Workflow condition groups | +| ↳ `steps` | array | Workflow steps | +| ↳ `include_private_incidents` | boolean | Whether the workflow includes private incidents | +| ↳ `include_private_escalations` | boolean | Whether the workflow includes private escalations | +| ↳ `runs_on_incident_modes` | array | Incident modes the workflow runs on | +| ↳ `continue_on_step_error` | boolean | Whether execution continues after a step error | +| ↳ `runs_on_incidents` | string | Incident lifecycle filter | +| ↳ `state` | string | Workflow state \(active, draft, disabled\) | +| ↳ `delay` | object | Workflow delay configuration | +| ↳ `folder` | string | Workflow folder | +| ↳ `runs_from` | string | When the workflow runs from | +| ↳ `shortform` | string | Workflow shortform identifier | +| `management_meta` | json | Workflow management metadata | ### `incidentio_workflows_show` @@ -468,18 +529,32 @@ Get details of a specific workflow in incident.io. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | | `id` | string | Yes | The ID of the workflow to retrieve \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | +| `skip_step_upgrades` | boolean | No | Skip workflow step upgrades when existing workflow step parameters changed | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `workflow` | object | The workflow details | -| ↳ `id` | string | Unique identifier for the workflow | -| ↳ `name` | string | Name of the workflow | -| ↳ `state` | string | State of the workflow \(active, draft, or disabled\) | -| ↳ `folder` | string | Folder the workflow belongs to | -| ↳ `created_at` | string | When the workflow was created | -| ↳ `updated_at` | string | When the workflow was last updated | +| ↳ `id` | string | Workflow ID | +| ↳ `name` | string | Workflow name | +| ↳ `trigger` | string | Workflow trigger | +| ↳ `once_for` | array | Fields that make the workflow run once | +| ↳ `version` | number | Workflow version | +| ↳ `expressions` | array | Workflow expressions | +| ↳ `condition_groups` | array | Workflow condition groups | +| ↳ `steps` | array | Workflow steps | +| ↳ `include_private_incidents` | boolean | Whether the workflow includes private incidents | +| ↳ `include_private_escalations` | boolean | Whether the workflow includes private escalations | +| ↳ `runs_on_incident_modes` | array | Incident modes the workflow runs on | +| ↳ `continue_on_step_error` | boolean | Whether execution continues after a step error | +| ↳ `runs_on_incidents` | string | Incident lifecycle filter | +| ↳ `state` | string | Workflow state \(active, draft, disabled\) | +| ↳ `delay` | object | Workflow delay configuration | +| ↳ `folder` | string | Workflow folder | +| ↳ `runs_from` | string | When the workflow runs from | +| ↳ `shortform` | string | Workflow shortform identifier | +| `management_meta` | json | Workflow management metadata | ### `incidentio_workflows_update` @@ -491,21 +566,43 @@ Update an existing workflow in incident.io. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | | `id` | string | Yes | The ID of the workflow to update \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | -| `name` | string | No | New name for the workflow \(e.g., "Notify on Critical Incidents"\) | +| `name` | string | Yes | New name for the workflow \(e.g., "Notify on Critical Incidents"\) | +| `steps` | string | Yes | Complete array of workflow steps as a JSON string | +| `condition_groups` | string | Yes | Complete array of workflow condition groups as a JSON string | +| `runs_on_incidents` | string | Yes | When to run the workflow: newly_created, newly_created_and_active, active, or all | +| `runs_on_incident_modes` | string | Yes | Complete array of incident modes to run on as a JSON string | +| `include_private_incidents` | boolean | Yes | Whether to include private incidents | +| `continue_on_step_error` | boolean | Yes | Whether to continue executing subsequent steps if a step fails | +| `once_for` | string | Yes | Complete array of fields that make the workflow run once as a JSON string | +| `expressions` | string | Yes | Complete array of workflow expressions as a JSON string | | `state` | string | No | New state for the workflow \(active, draft, or disabled\) | | `folder` | string | No | New folder for the workflow | +| `delay` | string | No | Delay configuration as a JSON string | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `workflow` | object | The updated workflow | -| ↳ `id` | string | Unique identifier for the workflow | -| ↳ `name` | string | Name of the workflow | -| ↳ `state` | string | State of the workflow \(active, draft, or disabled\) | -| ↳ `folder` | string | Folder the workflow belongs to | -| ↳ `created_at` | string | When the workflow was created | -| ↳ `updated_at` | string | When the workflow was last updated | +| ↳ `id` | string | Workflow ID | +| ↳ `name` | string | Workflow name | +| ↳ `trigger` | string | Workflow trigger | +| ↳ `once_for` | array | Fields that make the workflow run once | +| ↳ `version` | number | Workflow version | +| ↳ `expressions` | array | Workflow expressions | +| ↳ `condition_groups` | array | Workflow condition groups | +| ↳ `steps` | array | Workflow steps | +| ↳ `include_private_incidents` | boolean | Whether the workflow includes private incidents | +| ↳ `include_private_escalations` | boolean | Whether the workflow includes private escalations | +| ↳ `runs_on_incident_modes` | array | Incident modes the workflow runs on | +| ↳ `continue_on_step_error` | boolean | Whether execution continues after a step error | +| ↳ `runs_on_incidents` | string | Incident lifecycle filter | +| ↳ `state` | string | Workflow state \(active, draft, disabled\) | +| ↳ `delay` | object | Workflow delay configuration | +| ↳ `folder` | string | Workflow folder | +| ↳ `runs_from` | string | When the workflow runs from | +| ↳ `shortform` | string | Workflow shortform identifier | +| `management_meta` | json | Workflow management metadata | ### `incidentio_workflows_delete` @@ -647,6 +744,8 @@ List all escalation policies in incident.io | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | +| `page_size` | number | No | Number of escalations to return per page | +| `after` | string | No | Pagination cursor to fetch the next page of results | #### Output @@ -657,6 +756,9 @@ List all escalation policies in incident.io | ↳ `name` | string | The escalation policy name | | ↳ `created_at` | string | When the escalation policy was created | | ↳ `updated_at` | string | When the escalation policy was last updated | +| `pagination_meta` | object | Pagination metadata | +| ↳ `after` | string | Cursor for next page | +| ↳ `page_size` | number | Number of results per page | ### `incidentio_escalations_create` @@ -1096,29 +1198,18 @@ List all entries for a specific schedule in incident.io | `schedule_id` | string | Yes | The ID of the schedule to get entries for \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | | `entry_window_start` | string | No | Start date/time to filter entries in ISO 8601 format \(e.g., "2024-01-15T09:00:00Z"\) | | `entry_window_end` | string | No | End date/time to filter entries in ISO 8601 format \(e.g., "2024-01-22T09:00:00Z"\) | -| `page_size` | number | No | Number of results to return per page \(e.g., 10, 25, 50\) | -| `after` | string | No | Cursor for pagination \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `schedule_entries` | array | List of schedule entries | -| ↳ `id` | string | The entry ID | -| ↳ `schedule_id` | string | The schedule ID | -| ↳ `user` | object | User assigned to this entry | -| ↳ `id` | string | User ID | -| ↳ `name` | string | User name | -| ↳ `email` | string | User email | -| ↳ `start_at` | string | When the entry starts | -| ↳ `end_at` | string | When the entry ends | -| ↳ `layer_id` | string | The schedule layer ID | -| ↳ `created_at` | string | When the entry was created | -| ↳ `updated_at` | string | When the entry was last updated | +| `schedule_entries` | object | Schedule entries grouped by final, overrides, and scheduled entries | +| ↳ `final` | array | Final computed schedule entries | +| ↳ `overrides` | array | Override schedule entries | +| ↳ `scheduled` | array | Scheduled entries before overrides are applied | | `pagination_meta` | object | Pagination information | | ↳ `after` | string | Cursor for next page | | ↳ `after_url` | string | URL for next page | -| ↳ `page_size` | number | Number of results per page | ### `incidentio_schedule_overrides_create` @@ -1130,6 +1221,7 @@ Create a new schedule override in incident.io | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | | `rotation_id` | string | Yes | The ID of the rotation to override \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | +| `layer_id` | string | Yes | The ID of the layer this override applies to | | `schedule_id` | string | Yes | The ID of the schedule \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | | `user_id` | string | No | The ID of the user to assign \(provide one of: user_id, user_email, or user_slack_id\) | | `user_email` | string | No | The email of the user to assign \(provide one of: user_id, user_email, or user_slack_id\) | @@ -1143,6 +1235,7 @@ Create a new schedule override in incident.io | --------- | ---- | ----------- | | `override` | object | The created schedule override | | ↳ `id` | string | The override ID | +| ↳ `layer_id` | string | The schedule layer ID | | ↳ `rotation_id` | string | The rotation ID | | ↳ `schedule_id` | string | The schedule ID | | ↳ `user` | object | User assigned to this override | @@ -1154,6 +1247,31 @@ Create a new schedule override in incident.io | ↳ `created_at` | string | When the override was created | | ↳ `updated_at` | string | When the override was last updated | +### `incidentio_escalation_paths_list` + +List escalation paths in incident.io + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | incident.io API Key | +| `page_size` | number | No | Number of escalation paths to return per page | +| `after` | string | No | Pagination cursor to fetch the next page of results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `escalation_paths` | array | List of escalation paths | +| ↳ `id` | string | The escalation path ID | +| ↳ `name` | string | The escalation path name | +| ↳ `path` | array | Array of escalation levels | +| ↳ `working_hours` | array | Working hours configuration | +| `pagination_meta` | object | Pagination metadata | +| ↳ `after` | string | Cursor for next page | +| ↳ `page_size` | number | Number of results per page | + ### `incidentio_escalation_paths_create` Create a new escalation path in incident.io @@ -1186,8 +1304,6 @@ Create a new escalation path in incident.io | ↳ `weekday` | string | Day of week | | ↳ `start_time` | string | Start time | | ↳ `end_time` | string | End time | -| ↳ `created_at` | string | When the path was created | -| ↳ `updated_at` | string | When the path was last updated | ### `incidentio_escalation_paths_show` @@ -1219,8 +1335,6 @@ Get details of a specific escalation path in incident.io | ↳ `weekday` | string | Day of week | | ↳ `start_time` | string | Start time | | ↳ `end_time` | string | End time | -| ↳ `created_at` | string | When the path was created | -| ↳ `updated_at` | string | When the path was last updated | ### `incidentio_escalation_paths_update` @@ -1232,8 +1346,8 @@ Update an existing escalation path in incident.io | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | incident.io API Key | | `id` | string | Yes | The ID of the escalation path to update \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | -| `name` | string | No | New name for the escalation path \(e.g., "Critical Incident Path"\) | -| `path` | json | No | New escalation path configuration. Array of escalation levels with targets and time_to_ack_seconds | +| `name` | string | Yes | New name for the escalation path \(e.g., "Critical Incident Path"\) | +| `path` | json | Yes | New escalation path configuration. Array of escalation levels with targets and time_to_ack_seconds | | `working_hours` | json | No | New working hours configuration. Array of \{weekday, start_time, end_time\} | #### Output @@ -1255,8 +1369,6 @@ Update an existing escalation path in incident.io | ↳ `weekday` | string | Day of week | | ↳ `start_time` | string | Start time | | ↳ `end_time` | string | End time | -| ↳ `created_at` | string | When the path was created | -| ↳ `updated_at` | string | When the path was last updated | ### `incidentio_escalation_paths_delete` diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 5f04f102bb8..075f99a2954 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -17,6 +17,7 @@ "ashby", "athena", "attio", + "azure_devops", "box", "brandfetch", "brightdata", @@ -123,6 +124,7 @@ "mongodb", "mysql", "neo4j", + "new_relic", "notion", "obsidian", "okta", @@ -144,6 +146,7 @@ "pulse", "qdrant", "quiver", + "railway", "rds", "reddit", "redis", @@ -197,6 +200,7 @@ "webflow", "whatsapp", "wikipedia", + "wiza", "wordpress", "workday", "x", diff --git a/apps/docs/content/docs/en/tools/new_relic.mdx b/apps/docs/content/docs/en/tools/new_relic.mdx new file mode 100644 index 00000000000..7beb793335a --- /dev/null +++ b/apps/docs/content/docs/en/tools/new_relic.mdx @@ -0,0 +1,143 @@ +--- +title: New Relic +description: Query observability data and record deployments in New Relic +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[New Relic](https://newrelic.com/) is an observability platform for monitoring application performance, infrastructure, logs, traces, and business-impacting changes across your systems. It centralizes telemetry in NRDB and exposes that data through NerdGraph, New Relic's GraphQL API. + +With New Relic, you can: + +- **Query telemetry with NRQL**: Run NRQL against account data to inspect service health, usage, errors, latency, and custom events. +- **Find monitored entities**: Search services, applications, hosts, and other monitored resources by name, type, tags, alert state, or reporting status. +- **Fetch entity details**: Resolve an entity GUID into basic entity metadata for downstream workflow steps. +- **Record deployment changes**: Create deployment change tracking events with version, changelog, commit, build links, group IDs, and user context. + +Sim's New Relic integration lets agents pull production observability signals into workflows and annotate releases or operational changes directly from automation. Use it to summarize live service health, route incident workflows from entity searches, or mark deployments so New Relic charts can correlate performance changes with release activity. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate New Relic into workflows. Run NRQL queries, search monitored entities, fetch entity details, and record deployment change events. + + + +## Tools + +### `new_relic_nrql_query` + +Run a NRQL query against a New Relic account using NerdGraph. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | New Relic user API key for NerdGraph | +| `region` | string | No | New Relic data center region: us or eu | +| `accountId` | number | Yes | New Relic account ID to query | +| `nrql` | string | Yes | NRQL query to execute | +| `timeout` | number | No | Optional query timeout in seconds | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | NRQL result rows. Row fields depend on the query projection. | +| `resultCount` | number | Number of NRQL result rows returned | + +### `new_relic_search_entities` + +Search New Relic entities by name, GUID, domain type, tags, or reporting state. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | New Relic user API key for NerdGraph | +| `region` | string | No | New Relic data center region: us or eu | +| `query` | string | Yes | Entity search query, for example: name like "api" or domainType = "APM-APPLICATION" | +| `cursor` | string | No | Pagination cursor from a previous entity search | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `count` | number | Total number of entities matching the query | +| `query` | string | Entity search query New Relic executed | +| `entities` | array | Matching New Relic entities | +| ↳ `guid` | string | Entity GUID | +| ↳ `name` | string | Entity name | +| ↳ `entityType` | string | Entity type | +| `nextCursor` | string | Cursor for the next page of results | + +### `new_relic_get_entity` + +Fetch a New Relic entity by GUID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | New Relic user API key for NerdGraph | +| `region` | string | No | New Relic data center region: us or eu | +| `guid` | string | Yes | Entity GUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entity` | object | New Relic entity details | +| ↳ `guid` | string | Entity GUID | +| ↳ `name` | string | Entity name | +| ↳ `entityType` | string | Entity type | + +### `new_relic_create_deployment_event` + +Record a deployment change event in New Relic change tracking. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | New Relic user API key for NerdGraph | +| `region` | string | No | New Relic data center region: us or eu | +| `entityGuid` | string | Yes | GUID of the entity associated with the deployment | +| `version` | string | Yes | Deployment version, release name, or commit SHA | +| `shortDescription` | string | No | Short description of the deployment | +| `description` | string | No | Longer deployment description | +| `changelog` | string | No | Deployment changelog text or URL | +| `commit` | string | No | Commit SHA or identifier associated with the deployment | +| `deepLink` | string | No | URL to the deployment, build, or release details | +| `user` | string | No | User or automation that performed the deployment | +| `groupId` | string | No | Optional group ID to correlate related changes | +| `customAttributes` | json | No | Custom change event metadata as key-value pairs with string, number, or boolean values | +| `deploymentType` | string | No | Deployment type: basic, blue green, canary, rolling, or shadow | +| `timestamp` | number | No | Event timestamp in milliseconds since Unix epoch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | object | Created New Relic change tracking event | +| ↳ `changeTrackingId` | string | New Relic change tracking ID | +| ↳ `customAttributes` | json | Custom attributes on the change tracking event | +| ↳ `category` | string | Change category | +| ↳ `categoryAndType` | string | Combined category and type | +| ↳ `type` | string | Change type | +| ↳ `shortDescription` | string | Short change description | +| ↳ `description` | string | Change description | +| ↳ `timestamp` | number | Change timestamp in milliseconds | +| ↳ `user` | string | User associated with the change | +| ↳ `groupId` | string | Change group ID | +| ↳ `entity` | object | Entity associated with the change | +| ↳ `guid` | string | Entity GUID | +| ↳ `name` | string | Entity name | +| `messages` | array | Messages returned by New Relic for the created change event | diff --git a/apps/docs/content/docs/en/tools/railway.mdx b/apps/docs/content/docs/en/tools/railway.mdx new file mode 100644 index 00000000000..ab26d387838 --- /dev/null +++ b/apps/docs/content/docs/en/tools/railway.mdx @@ -0,0 +1,343 @@ +--- +title: Railway +description: Manage Railway projects, deployments, and variables +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Railway](https://railway.com/) is a cloud application platform for deploying, operating, and scaling services, databases, jobs, and production environments from a single project workspace. Teams use Railway to connect source repositories, manage environments, configure variables, trigger deployments, and monitor delivery across staging and production. + +With Railway, you can: + +- **Manage projects and environments**: Organize deployed services and inspect the environments attached to each project +- **Automate deployments**: Trigger new service deployments and inspect recent deployment status from workflows +- **Control runtime configuration**: Read and update environment variables for services or shared project environments +- **Connect infrastructure workflows**: Use project, service, and environment IDs from one step to drive release automation in later steps + +In Sim, the Railway integration lets your agents work with Railway's public GraphQL API directly from workflows. You can list projects, fetch project services and environments, inspect deployments, deploy a service, and manage environment variables as part of CI/CD, operations, and release processes. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Railway into workflows to list projects, inspect services and environments, monitor deployments, trigger service deployments, and manage environment variables. + + + +## Tools + +### `railway_list_projects` + +List Railway projects visible to the provided token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `workspaceId` | string | No | Workspace ID to list projects from | +| `first` | number | No | Maximum number of projects to return | +| `after` | string | No | Cursor for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | array | Railway projects | +| ↳ `id` | string | Project ID | +| ↳ `name` | string | Project name | +| ↳ `description` | string | Project description | +| ↳ `createdAt` | string | Project creation timestamp | +| ↳ `updatedAt` | string | Project update timestamp | +| `pageInfo` | object | Pagination information | +| ↳ `hasNextPage` | boolean | Whether more projects are available | +| ↳ `endCursor` | string | Cursor for the next page | +| `count` | number | Number of projects returned | + +### `railway_get_project` + +Get a Railway project with its services and environments + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | Project with services and environments | +| ↳ `id` | string | Project ID | +| ↳ `name` | string | Project name | +| ↳ `description` | string | Project description | +| ↳ `createdAt` | string | Project creation timestamp | +| ↳ `services` | array | Project services | +| ↳ `id` | string | Service ID | +| ↳ `name` | string | Service name | +| ↳ `icon` | string | Service icon | +| ↳ `environments` | array | Project environments | +| ↳ `id` | string | Environment ID | +| ↳ `name` | string | Environment name | + +### `railway_create_project` + +Create a Railway project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `name` | string | Yes | Project name | +| `description` | string | No | Project description | +| `workspaceId` | string | No | Workspace ID to create the project in | +| `isPublic` | boolean | No | Whether the project should be publicly visible | +| `defaultEnvironmentName` | string | No | Name for the default environment | +| `prDeploys` | boolean | No | Whether to enable pull request deploys | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | Created project | +| ↳ `id` | string | Project ID | +| ↳ `name` | string | Project name | + +### `railway_update_project` + +Update a Railway project name or description + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | +| `name` | string | No | Updated project name | +| `description` | string | No | Updated project description | +| `isPublic` | boolean | No | Whether the project should be publicly visible | +| `prDeploys` | boolean | No | Whether to enable pull request deploy environments | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | Updated project | +| ↳ `id` | string | Project ID | +| ↳ `name` | string | Project name | +| ↳ `description` | string | Project description | + +### `railway_delete_project` + +Delete a Railway project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the project was deleted | + +### `railway_transfer_project` + +Transfer a Railway project to another workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | +| `workspaceId` | string | Yes | Destination workspace ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the project was transferred | + +### `railway_list_project_members` + +List members for a Railway project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Project members | +| ↳ `id` | string | Project membership ID | +| ↳ `role` | string | Project role | +| ↳ `user` | object | Railway user | +| ↳ `id` | string | User ID | +| ↳ `name` | string | User name | +| ↳ `email` | string | User email | +| `count` | number | Number of members returned | + +### `railway_create_environment` + +Create a Railway project environment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | +| `name` | string | Yes | Environment name | +| `sourceEnvironmentId` | string | No | Environment ID to clone from | +| `ephemeral` | boolean | No | Whether the environment is ephemeral | +| `skipInitialDeploys` | boolean | No | Whether to skip initial deploys for the environment | +| `stageInitialChanges` | boolean | No | Whether to stage initial changes instead of applying them immediately | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `environment` | object | Created environment | +| ↳ `id` | string | Environment ID | +| ↳ `name` | string | Environment name | + +### `railway_delete_environment` + +Delete a Railway project environment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `environmentId` | string | Yes | Railway environment ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the environment was deleted | + +### `railway_list_deployments` + +List deployments for a Railway service in an environment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | +| `serviceId` | string | Yes | Railway service ID | +| `environmentId` | string | Yes | Railway environment ID | +| `first` | number | No | Maximum number of deployments to return | +| `after` | string | No | Cursor for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deployments` | array | Service deployments | +| ↳ `id` | string | Deployment ID | +| ↳ `status` | string | Deployment status | +| ↳ `createdAt` | string | Deployment creation timestamp | +| ↳ `url` | string | Deployment URL | +| ↳ `staticUrl` | string | Static deployment URL | +| `count` | number | Number of deployments returned | +| `pageInfo` | object | Pagination information | +| ↳ `hasNextPage` | boolean | Whether more deployments are available | +| ↳ `endCursor` | string | Cursor for the next page | + +### `railway_deploy_service` + +Trigger a deployment for a Railway service in an environment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `serviceId` | string | Yes | Railway service ID | +| `environmentId` | string | Yes | Railway environment ID | +| `commitSha` | string | No | Specific Git commit SHA to deploy | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deploymentId` | string | Created deployment ID | + +### `railway_list_variables` + +List Railway environment variables for a service or shared environment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | +| `environmentId` | string | Yes | Railway environment ID | +| `serviceId` | string | No | Railway service ID. Omit for shared environment variables. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `variables` | object | Variable names and values | +| `count` | number | Number of variables returned | + +### `railway_upsert_variable` + +Create or update a Railway environment variable + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Railway API token | +| `tokenType` | string | No | Railway token type: account, workspace, project, or oauth | +| `projectId` | string | Yes | Railway project ID | +| `environmentId` | string | Yes | Railway environment ID | +| `serviceId` | string | No | Railway service ID. Omit to create or update a shared variable. | +| `name` | string | Yes | Variable name | +| `value` | string | Yes | Variable value | +| `skipDeploys` | boolean | No | Whether to skip automatic redeploys after changing the variable | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the variable was created or updated | + + diff --git a/apps/docs/content/docs/en/tools/video_generator.mdx b/apps/docs/content/docs/en/tools/video_generator.mdx index a33492c95b9..b2e7c9fce54 100644 --- a/apps/docs/content/docs/en/tools/video_generator.mdx +++ b/apps/docs/content/docs/en/tools/video_generator.mdx @@ -6,29 +6,27 @@ description: Generate videos from text using AI import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} -Create videos from text prompts using cutting-edge AI models from top providers. Sim's Video Generator brings powerful, creative video synthesis capabilities to your workflow—supporting diverse models, aspect ratios, resolutions, camera controls, native audio, and advanced style and consistency features. +Create videos from text prompts using leading AI video providers. Sim's Video Generator supports direct provider integrations for Runway, Google Veo, Luma, and MiniMax, plus a Fal.ai multi-model provider for newer and specialized models. **Supported Providers & Models:** -- **[Runway Gen-4](https://research.runwayml.com/gen2/)** (Runway ML): - Runway is a pioneer in text-to-video generation, known for powerful models like Gen-2, Gen-3, and Gen-4. The latest [Gen-4](https://research.runwayml.com/gen2/) model (and Gen-4 Turbo for faster results) supports more realistic motion, greater world consistency, and visual references for character, object, style, and location. Supports 16:9, 9:16, and 1:1 aspect ratios, 5–10 second durations, up to 4K resolution, style presets, and direct upload of reference images for consistent generations. Runway powers creative tools for filmmakers, studios, and content creators worldwide. +- **[Runway Gen-4](https://docs.dev.runwayml.com/)**: Generate image-to-video clips with a required reference image, 5 or 10 second durations, and landscape, portrait, or square output. -- **[Google Veo](https://deepmind.google/technologies/veo/)** (Google DeepMind): - [Veo](https://deepmind.google/technologies/veo/) is Google’s next-generation video generation model, offering high-quality, native-audio videos up to 1080p and 16 seconds. Supports advanced motion, cinematic effects, and nuanced text understanding. Veo can generate videos with built-in sound—activating native audio as well as silent clips. Options include 16:9 aspect, variable duration, different models (veo-3, veo-3.1), and prompt-based controls. Ideal for storytelling, advertising, research, and ideation. +- **[Google Veo](https://ai.google.dev/gemini-api/docs/video)**: Generate text-to-video clips with Veo 3 and Veo 3.1 models, portrait or landscape aspect ratios, 4, 6, or 8 second durations, and 720p or 1080p output. -- **[Luma Dream Machine](https://lumalabs.ai/dream-machine)** (Luma AI): - [Dream Machine](https://lumalabs.ai/dream-machine) delivers jaw-droppingly realistic and fluid video from text. It incorporates advanced camera control, cinematography prompts, and supports both ray-1 and ray-2 models. Dream Machine supports precise aspect ratios (16:9, 9:16, 1:1), variable durations, and the specification of camera paths for intricate visual direction. Luma is renowned for breakthrough visual fidelity and is backed by leading AI vision researchers. +- **[Luma Dream Machine](https://docs.lumalabs.ai/docs/video-generation)**: Generate Ray 2 videos with 5 or 9 second durations, common aspect ratios, multiple resolutions, and optional camera concept controls. -- **[MiniMax Hailuo-02](https://minimax.chat/)** (via [Fal.ai](https://fal.ai/)): - [MiniMax Hailuo-02](https://minimax.chat/) is a sophisticated Chinese generative video model, available globally through [Fal.ai](https://fal.ai/). Generate videos up to 16 seconds in landscape or portrait format, with options for prompt optimization to improve clarity and creativity. Pro and standard endpoints available, supporting high resolutions (up to 1920×1080). Well-suited for creative projects needing prompt translation and optimization, commercial storytelling, and rapid prototyping of visual ideas. +- **[MiniMax Hailuo](https://platform.minimax.io/docs/api-reference/video-generation-t2v)**: Generate Hailuo 2.3 or Hailuo-02 videos through MiniMax's platform API, with standard or pro quality endpoints and prompt optimization. + +- **[Fal.ai Multi-Model](https://fal.ai/docs/model-api-reference/video-generation-api/overview)**: Access Veo 3.1, Sora 2, Seedance 2.0, Kling 3.0 and O3, MiniMax Hailuo 2.3, WAN 2.2, LTX 2.3, and previously supported Fal.ai models from one provider option. **How to Choose:** -Pick your provider and model based on your needs for quality, speed, duration, audio, cost, and unique features. Runway and Veo offer world-leading realism and cinematic capabilities; Luma excels in fluid motion and camera control; MiniMax is ideal for Chinese-language prompts and offers fast, affordable access. Consider reference support, style presets, audio requirements, and pricing when selecting your tool. +Pick the provider and model based on quality, speed, duration, audio support, reference image needs, resolution, and cost. Runway is best when you have a visual reference, Veo and Luma are strong general text-to-video options, MiniMax offers a direct Hailuo API path, and Fal.ai is the best choice when you need access to the broadest model catalog. For more details on features, restrictions, pricing, and model advances, see each provider’s official documentation above. {/* MANUAL-CONTENT-END */} @@ -36,7 +34,7 @@ For more details on features, restrictions, pricing, and model advances, see eac ## Usage Instructions -Generate high-quality videos from text prompts using leading AI providers. Supports multiple models, aspect ratios, resolutions, and provider-specific features like world consistency, camera controls, and audio generation. +Generate high-quality videos from text prompts using leading AI providers. Supports Runway, Google Veo, Luma, MiniMax, and Fal.ai multi-model generation with provider-specific durations, aspect ratios, resolutions, prompt optimization, and native audio controls. @@ -141,9 +139,10 @@ Generate videos using MiniMax Hailuo through MiniMax Platform API with advanced | --------- | ---- | -------- | ----------- | | `provider` | string | Yes | Video provider \(minimax\) | | `apiKey` | string | Yes | MiniMax API key from platform.minimax.io | -| `model` | string | No | MiniMax model: hailuo-02 \(default\) | +| `model` | string | No | MiniMax model: hailuo-2.3 \(default\) or hailuo-02 | | `prompt` | string | Yes | Text prompt describing the video to generate | | `duration` | number | No | Video duration in seconds \(6 or 10, default: 6\) | +| `endpoint` | string | No | Quality endpoint: standard \(768P\) or pro \(1080P for 6s videos\) | | `promptOptimizer` | boolean | No | Enable prompt optimization for better results \(default: true\) | #### Output @@ -161,7 +160,7 @@ Generate videos using MiniMax Hailuo through MiniMax Platform API with advanced ### `video_falai` -Generate videos using Fal.ai platform with access to multiple models including Veo 3.1, Sora 2, Kling 2.5, MiniMax Hailuo, and more +Generate videos using Fal.ai with access to Veo 3.1, Sora 2, Seedance 2.0, Kling 3.0, MiniMax Hailuo 2.3, WAN 2.2, LTX 2.3, and previously supported models #### Input @@ -169,12 +168,13 @@ Generate videos using Fal.ai platform with access to multiple models including V | --------- | ---- | -------- | ----------- | | `provider` | string | Yes | Video provider \(falai\) | | `apiKey` | string | Yes | Fal.ai API key | -| `model` | string | Yes | Fal.ai model: veo-3.1 \(Google Veo 3.1\), sora-2 \(OpenAI Sora 2\), kling-2.5-turbo-pro \(Kling 2.5 Turbo Pro\), kling-2.1-pro \(Kling 2.1 Master\), minimax-hailuo-2.3-pro \(MiniMax Hailuo Pro\), minimax-hailuo-2.3-standard \(MiniMax Hailuo Standard\), wan-2.1 \(WAN T2V\), ltxv-0.9.8 \(LTXV 13B\) | +| `model` | string | Yes | Fal.ai model: veo-3.1, veo-3.1-fast, sora-2, sora-2-pro, seedance-2.0, seedance-2.0-fast, kling-v3-pro, kling-v3-4k, kling-o3-pro, kling-o3-4k, minimax-hailuo-2.3-pro, minimax-hailuo-2.3-standard, wan-2.2-a14b-turbo, ltx-2.3, ltx-2.3-fast, plus previously supported model IDs | | `prompt` | string | Yes | Text prompt describing the video to generate | | `duration` | number | No | Video duration in seconds \(varies by model\) | | `aspectRatio` | string | No | Aspect ratio \(varies by model\): 16:9, 9:16, 1:1 | -| `resolution` | string | No | Video resolution \(varies by model\): 540p, 720p, 1080p | +| `resolution` | string | No | Video resolution \(varies by model\): 480p, 580p, 720p, 1080p, true_1080p, 1440p, 2160p, 4k | | `promptOptimizer` | boolean | No | Enable prompt optimization for MiniMax models \(default: true\) | +| `generateAudio` | boolean | No | Generate native audio when supported by the selected Fal.ai model | #### Output diff --git a/apps/docs/content/docs/en/tools/wiza.mdx b/apps/docs/content/docs/en/tools/wiza.mdx new file mode 100644 index 00000000000..e8d775b07a1 --- /dev/null +++ b/apps/docs/content/docs/en/tools/wiza.mdx @@ -0,0 +1,251 @@ +--- +title: Wiza +description: Find, enrich, and verify B2B contact data with Wiza +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Wiza](https://wiza.co/) is a B2B contact data platform that finds and verifies emails and phone numbers for sales, recruiting, and marketing teams. Wiza pairs a global prospect database with real-time enrichment, so the data you act on stays accurate and deliverable. + +With Wiza, you can: + +- **Search a global prospect database**: Find people using person, company, and financial filters +- **Enrich companies**: Resolve firmographic data from a name, domain, or LinkedIn URL +- **Reveal individual contacts**: Get verified work emails, personal emails, and mobile phone numbers +- **Track credit usage**: Check remaining email, phone, export, and API credits at any time + +In Sim, the Wiza integration lets your agents drive prospecting and enrichment workflows end-to-end: + +- **Prospect Search**: Use `wiza_prospect_search` to discover prospects matching detailed filters. +- **Company Enrichment**: Use `wiza_company_enrichment` to enrich a company from name, domain, LinkedIn ID, or LinkedIn slug. +- **Start Individual Reveal**: Use `wiza_start_individual_reveal` to begin enrichment for a contact via LinkedIn URL, name + company, or email — with configurable enrichment depth (`none`, `partial`, `phone`, `full`). +- **Get Individual Reveal**: Use `wiza_get_individual_reveal` to poll a reveal and retrieve verified emails, phones, and full company context. +- **Get Credits**: Use `wiza_get_credits` to monitor remaining credit balances before scaling up runs. + +Individual reveals are asynchronous: start a reveal, then poll `wiza_get_individual_reveal` until `is_complete` is `true`. Combine these tools to build agents that source, enrich, and qualify leads on demand — without leaving your workflow. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +{/* MANUAL-CONTENT-START:usage */} +### Wiza API Key Setup + +Wiza authenticates via API key. To get yours: + +1. Log in to your [Wiza account](https://app.wiza.co/). +2. Open **Settings → API** and generate a new API key (or copy an existing one). +3. In Sim, open the Wiza block and paste the key into the **Wiza API Key** field. + +The same key is used for every Wiza operation. Wiza enforces a rate limit of 30 requests per minute (43,200 per day) per key. + +### Individual Reveals Are Asynchronous + +`wiza_start_individual_reveal` returns immediately with a reveal `id` and a `status` of `queued` or `resolving`. To retrieve the enriched contact data, call `wiza_get_individual_reveal` with that `id` and check `is_complete`. Possible statuses are `queued`, `resolving`, `finished`, and `failed`. + +For real-time delivery without polling, pass a `callback_url` when starting the reveal — Wiza will POST the completed payload to that URL. + +### Enrichment Levels and Credits + +When starting an individual reveal, choose the enrichment level that matches the data you need: + +- **`none`** — Identity only, no contact info (no credit spend) +- **`partial`** — Verified work email (email credits) +- **`phone`** — Mobile phone (phone credits) +- **`full`** — Email + phone (email + phone credits) + +Use `wiza_get_credits` to monitor remaining email, phone, export, and API credits before running large batches. +{/* MANUAL-CONTENT-END */} + + +Integrates Wiza into the workflow. Search prospects, enrich companies, reveal verified emails and phone numbers for individuals, and check your account credit balance. + + + +## Tools + +### `wiza_prospect_search` + +Search Wiza + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Wiza API key | +| `size` | number | No | Number of sample profiles to return \(0-30, default 0\) | +| `filters` | object | No | Full filters object \(overrides individual filter params if provided\) | +| `first_name` | array | No | Exact first names to match \(e.g., \["John", "Jane"\]\) | +| `last_name` | array | No | Exact last names to match | +| `job_title` | array | No | Job titles to include/exclude \(e.g., \[\{"v":"CEO","s":"i"\},\{"v":"CTO","s":"e"\}\]\) | +| `job_title_level` | array | No | Seniority levels \(e.g., \["cxo", "director", "manager"\]\) | +| `job_role` | array | No | Job role categories \(e.g., \["sales", "engineering", "marketing"\]\) | +| `job_sub_role` | array | No | Detailed role categories \(e.g., \["software", "product"\]\) | +| `location` | array | No | Person's location filters \(city/state/country with include/exclude\) | +| `skill` | array | No | Professional skills \(e.g., \["python", "marketing"\]\) | +| `school` | array | No | Educational institutions | +| `major` | array | No | Field of study | +| `linkedin_slug` | array | No | LinkedIn profile slugs | +| `job_company` | array | No | Current company filters \(include/exclude\) | +| `past_company` | array | No | Past company filters | +| `company_location` | array | No | Company HQ location filters | +| `company_industry` | array | No | Company industry filters \(include/exclude\) | +| `company_size` | array | No | Company headcount brackets \(e.g., \["1-10", "11-50", "51-200"\]\) | +| `company_type` | array | No | Company type \(e.g., \["private", "public", "educational"\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `total` | number | Total number of matching prospects | +| `profiles` | array | Sample profiles matching the filter criteria | + +### `wiza_company_enrichment` + +Enrich a company by name, domain, LinkedIn ID, or LinkedIn slug with detailed firmographic data + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Wiza API key | +| `company_name` | string | No | Company name \(e.g., "Wiza"\) | +| `company_domain` | string | No | Company domain \(e.g., "wiza.co"\) | +| `company_linkedin_id` | string | No | Company LinkedIn ID | +| `company_linkedin_slug` | string | No | Company LinkedIn slug from the URL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `company_name` | string | Company name | +| `company_domain` | string | Company domain | +| `domain` | string | Domain | +| `company_industry` | string | Industry | +| `company_size` | number | Employee count | +| `company_size_range` | string | Headcount range | +| `company_founded` | number | Year founded | +| `company_revenue_range` | string | Revenue range | +| `company_funding` | string | Total funding | +| `company_type` | string | Company type | +| `company_description` | string | Description | +| `company_ticker` | string | Stock ticker | +| `company_last_funding_round` | string | Last funding round | +| `company_last_funding_amount` | string | Last funding amount | +| `company_last_funding_at` | string | Last funding date | +| `company_location` | string | Full location string | +| `company_twitter` | string | Twitter URL | +| `company_facebook` | string | Facebook URL | +| `company_linkedin` | string | LinkedIn URL | +| `company_linkedin_id` | string | LinkedIn ID | +| `company_street` | string | Street address | +| `company_locality` | string | City | +| `company_region` | string | State/region | +| `company_postal_code` | string | Postal code | +| `company_country` | string | Country | +| `credits` | json | Remaining API credits | + +### `wiza_start_individual_reveal` + +Start an individual reveal to enrich a contact via LinkedIn URL, name+company, or email + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Wiza API key | +| `enrichment_level` | string | Yes | Enrichment depth: none, partial, phone, or full | +| `profile_url` | string | No | LinkedIn profile URL \(e.g., https://linkedin.com/in/johndoe\) | +| `full_name` | string | No | Full name \(used with company or domain\) | +| `company` | string | No | Company name \(used with full_name\) | +| `domain` | string | No | Company domain \(used with full_name\) | +| `email` | string | No | Email address \(use alone or with other identifiers\) | +| `accept_work` | boolean | No | Whether to accept work emails \(email_options\) | +| `accept_personal` | boolean | No | Whether to accept personal emails \(email_options\) | +| `callback_url` | string | No | Optional URL to receive a callback with the reveal update | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Individual reveal ID \(use with Get Individual Reveal\) | +| `status` | string | Reveal status: queued, resolving, finished, or failed | +| `is_complete` | boolean | Whether the reveal has completed | + +### `wiza_get_individual_reveal` + +Retrieve the status and enriched data for an individual reveal by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Wiza API key | +| `id` | string | Yes | Individual reveal ID returned from Start Individual Reveal | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Reveal ID | +| `status` | string | queued \| resolving \| finished \| failed | +| `is_complete` | boolean | Whether the reveal has completed | +| `name` | string | Full name | +| `company` | string | Company name | +| `enrichment_level` | string | Enrichment level used | +| `linkedin_profile_url` | string | LinkedIn URL | +| `title` | string | Job title | +| `location` | string | Location | +| `email` | string | Primary email | +| `email_type` | string | Email type | +| `email_status` | string | valid \| risky \| unfound | +| `emails` | array | All emails found | +| `mobile_phone` | string | Mobile phone | +| `phone_number` | string | Direct/office phone | +| `phone_status` | string | found \| unfound | +| `phones` | array | All phones found | +| `company_size` | number | Employee count | +| `company_size_range` | string | Headcount range | +| `company_type` | string | Company type | +| `company_domain` | string | Company domain | +| `company_locality` | string | City | +| `company_region` | string | State/region | +| `company_country` | string | Country | +| `company_street` | string | Street | +| `company_postal_code` | string | Postal code | +| `company_founded` | number | Year founded | +| `company_funding` | string | Funding total | +| `company_revenue` | string | Revenue | +| `company_industry` | string | Industry | +| `company_subindustry` | string | Subindustry | +| `company_linkedin` | string | Company LinkedIn URL | +| `company_location` | string | Full company location | +| `company_description` | string | Company description | +| `credits` | json | Remaining credits balance | + +### `wiza_get_credits` + +Retrieve the remaining credits on your Wiza account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Wiza API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `email_credits` | json | Remaining email credits \(number or "unlimited"\) | +| `phone_credits` | json | Remaining phone credits \(number or "unlimited"\) | +| `export_credits` | number | Remaining export credits | +| `api_credits` | number | Remaining API credits | + + diff --git a/apps/docs/content/docs/en/triggers/azure_devops.mdx b/apps/docs/content/docs/en/triggers/azure_devops.mdx new file mode 100644 index 00000000000..d22c9ae8257 --- /dev/null +++ b/apps/docs/content/docs/en/triggers/azure_devops.mdx @@ -0,0 +1,83 @@ +--- +title: Azure Devops +description: Available Azure Devops triggers for automating workflows +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +Azure Devops provides 3 triggers for automating workflows based on events. + +## Triggers + +### Azure DevOps Build Failed + +Trigger workflow when an Azure DevOps build fails, is canceled, or partially succeeds + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `buildId` | number | Build ID | +| `buildNumber` | string | Build number string \(e.g. 20240101.1\) | +| `result` | string | Build result: failed \| canceled \| partiallySucceeded | +| `pipelineId` | number | Pipeline definition ID | +| `pipelineName` | string | Pipeline definition name | +| `projectName` | string | Azure DevOps project name | +| `branch` | string | Source branch name \(refs/heads/ prefix stripped\) | +| `commitSha` | string | Source commit SHA | +| `triggeredBy` | string | Display name of the person who triggered the build | +| `triggeredByEmail` | string | Email/unique name of the person who triggered the build | +| `startTime` | string | Build start time \(ISO 8601\) | +| `finishTime` | string | Build finish time \(ISO 8601\) | +| `buildUrl` | string | API URL for the build resource | + + +--- + +### Azure DevOps Webhook (All Service Hook Events) + +Trigger on whichever service hook event types you configure in Azure DevOps. Sim does not filter deliveries for this trigger. + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Service hook event type \(e.g. build.complete, workitem.created\) | +| `notificationId` | number | Notification ID | +| `subscriptionId` | string | Service hook subscription ID | +| `publisherId` | string | Publisher ID \(e.g. tfs\) | +| `createdDate` | string | Event creation time \(ISO 8601\) | +| `resource` | json | Event resource payload | +| `resourceContainers` | json | Resource container references \(project, collection, etc.\) | +| `message` | json | Short message object | +| `detailedMessage` | json | Detailed message object | + + +--- + +### Azure DevOps Work Item Created + +Trigger workflow when a work item is created in Azure DevOps + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workItemId` | number | Work item ID | +| `workItemType` | string | Work item type for Basic process \(e.g. Issue, Task, Epic\) | +| `title` | string | Work item title | +| `state` | string | Work item state for Basic process \(e.g. To Do, Doing, Done\) | +| `createdBy` | string | Display name of the creator | +| `assignedTo` | string | Assignee display name, or empty string if unassigned | +| `priority` | number | Priority \(1–4\), or 0 if not set | +| `areaPath` | string | Area path | +| `iterationPath` | string | Iteration path | +| `description` | string | Work item description \(HTML\), or empty string if not set | +| `projectName` | string | Azure DevOps project name | +| `workItemUrl` | string | API URL for the work item resource | + diff --git a/apps/docs/content/docs/en/triggers/meta.json b/apps/docs/content/docs/en/triggers/meta.json index 70d13afd920..ab14483dd52 100644 --- a/apps/docs/content/docs/en/triggers/meta.json +++ b/apps/docs/content/docs/en/triggers/meta.json @@ -8,6 +8,7 @@ "airtable", "ashby", "attio", + "azure_devops", "calcom", "calendly", "circleback", diff --git a/apps/sim/app/(auth)/components/auth-button-classes.ts b/apps/sim/app/(auth)/components/auth-button-classes.ts index a55f334ea8e..95a3592fe96 100644 --- a/apps/sim/app/(auth)/components/auth-button-classes.ts +++ b/apps/sim/app/(auth)/components/auth-button-classes.ts @@ -4,3 +4,7 @@ export const AUTH_PRIMARY_CTA_BASE = /** Full-width variant used for primary auth form submit buttons. */ export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const + +/** Shared className for inline auth action links on dark auth surfaces. */ +export const AUTH_TEXT_LINK = + 'font-medium text-[var(--brand-accent)] underline-offset-4 transition hover:text-[var(--brand-accent-hover)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' as const diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index 64fe8a8556d..2394d741345 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -20,6 +20,7 @@ import { AshbyIcon, AthenaIcon, AttioIcon, + AzureDevOpsIcon, AzureIcon, BoxCompanyIcon, BrainIcon, @@ -127,6 +128,7 @@ import { MongoDBIcon, MySQLIcon, Neo4jIcon, + NewRelicIcon, NotionIcon, ObsidianIcon, OktaIcon, @@ -148,6 +150,7 @@ import { PulseIcon, QdrantIcon, QuiverIcon, + RailwayIcon, RDSIcon, RedditIcon, RedisIcon, @@ -197,6 +200,7 @@ import { WebflowIcon, WhatsAppIcon, WikipediaIcon, + WizaIcon, WordpressIcon, WorkdayIcon, xIcon, @@ -225,6 +229,7 @@ export const blockTypeToIconMap: Record = { ashby: AshbyIcon, athena: AthenaIcon, attio: AttioIcon, + azure_devops: AzureDevOpsIcon, box: BoxCompanyIcon, brandfetch: BrandfetchIcon, brightdata: BrightDataIcon, @@ -297,7 +302,7 @@ export const blockTypeToIconMap: Record = { hunter: HunterIOIcon, iam: IAMIcon, identity_center: IdentityCenterIcon, - image_generator: ImageIcon, + image_generator_v2: ImageIcon, imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, @@ -330,6 +335,7 @@ export const blockTypeToIconMap: Record = { mongodb: MongoDBIcon, mysql: MySQLIcon, neo4j: Neo4jIcon, + new_relic: NewRelicIcon, notion_v2: NotionIcon, obsidian: ObsidianIcon, okta: OktaIcon, @@ -351,6 +357,7 @@ export const blockTypeToIconMap: Record = { pulse_v2: PulseIcon, qdrant: QdrantIcon, quiver: QuiverIcon, + railway: RailwayIcon, rds: RDSIcon, reddit: RedditIcon, redis: RedisIcon, @@ -397,12 +404,13 @@ export const blockTypeToIconMap: Record = { typeform: TypeformIcon, upstash: UpstashIcon, vercel: VercelIcon, - video_generator_v2: VideoIcon, + video_generator_v3: VideoIcon, vision_v2: EyeIcon, wealthbox: WealthboxIcon, webflow: WebflowIcon, whatsapp: WhatsAppIcon, wikipedia: WikipediaIcon, + wiza: WizaIcon, wordpress: WordpressIcon, workday: WorkdayIcon, x: xIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index d67628c4ab8..fdf1e75c533 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -1882,6 +1882,105 @@ "integrationTypes": ["security"], "tags": ["identity", "microsoft-365"] }, + { + "type": "azure_devops", + "slug": "azure-devops", + "name": "Azure DevOps", + "description": "Interact with Azure DevOps pipelines, builds, and work items", + "longDescription": "Integrate Azure DevOps into your workflow. List and inspect pipelines and builds, query and manage work items, and add or read comments.", + "bgColor": "#0078D4", + "iconName": "AzureDevOpsIcon", + "docsUrl": "https://docs.sim.ai/tools/azure_devops", + "operations": [ + { + "name": "List Pipelines", + "description": "List all pipelines in an Azure DevOps project. Returns pipeline ID, name, folder, revision, and URL." + }, + { + "name": "Get Pipeline", + "description": "Get details for a specific pipeline in an Azure DevOps project, including configuration and repository info." + }, + { + "name": "List Pipeline Runs", + "description": "List runs for a specific pipeline in an Azure DevOps project. Returns run ID, name, state, result, and timestamps." + }, + { + "name": "Get Pipeline Run", + "description": "Get details for a specific pipeline run in an Azure DevOps project. Returns run state, result, timestamps, and the pipeline reference." + }, + { + "name": "List Builds", + "description": "List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, or branch." + }, + { + "name": "List Build Logs", + "description": "List all log entries for a specific build in Azure DevOps. Returns log IDs, types, and line counts — use the log ID with the Get Build Log tool to fetch actual log text." + }, + { + "name": "Get Build Log", + "description": "Fetch the text content of a specific build log in Azure DevOps. Use List Build Logs first to get the log ID. Optionally retrieve only a line range with startLine/endLine." + }, + { + "name": "Get Build Timeline", + "description": "Get the execution timeline for an Azure DevOps build — every stage, job, and task with its result and log ID. Use this to identify which steps failed before fetching their logs with Get Build Log." + }, + { + "name": "Get Work Items Between Builds", + "description": "Get work item references associated with commits between two builds in Azure DevOps. Returns work item IDs and URLs — use Get Work Items Batch for full field details." + }, + { + "name": "Query Work Items", + "description": "Execute a WIQL query to search for work items in Azure DevOps and return full field details. Use TOP N in your query to limit results (Azure enforces a 200-item maximum per batch fetch)." + }, + { + "name": "Get Work Item", + "description": "Fetch full details of a single work item by ID from Azure DevOps, including title, state, type, assignee, and area path." + }, + { + "name": "Get Work Items Batch", + "description": "Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. " + }, + { + "name": "Create Work Item", + "description": "Create a new Basic-process work item (Issue, Task, or Epic) in Azure DevOps. Returns the created work item with its assigned ID." + }, + { + "name": "Update Work Item", + "description": "Update one or more fields on an existing work item in Azure DevOps. Provide only the fields you want to change." + }, + { + "name": "Add Comment", + "description": "Add a comment to a work item in Azure DevOps." + }, + { + "name": "Get Comments", + "description": "List comments for an Azure DevOps work item." + } + ], + "operationCount": 16, + "triggers": [ + { + "id": "azure_devops_build_failed", + "name": "Azure DevOps Build Failed", + "description": "Trigger workflow when an Azure DevOps build fails, is canceled, or partially succeeds" + }, + { + "id": "azure_devops_work_item_created", + "name": "Azure DevOps Work Item Created", + "description": "Trigger workflow when a work item is created in Azure DevOps" + }, + { + "id": "azure_devops_webhook", + "name": "Azure DevOps Webhook (All Service Hook Events)", + "description": "Trigger on whichever service hook event types you configure in Azure DevOps. Sim does not filter deliveries for this trigger." + } + ], + "triggerCount": 3, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["developer-tools", "productivity"], + "tags": ["ci-cd", "project-management", "version-control"] + }, { "type": "box", "slug": "box", @@ -4057,6 +4156,10 @@ "name": "Fetch", "description": "Fetch and parse a file from a URL with optional custom headers." }, + { + "name": "Get", + "description": "Get a workspace file object from a selected file or canonical workspace file ID." + }, { "name": "Write", "description": "Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., " @@ -4066,7 +4169,7 @@ "description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file." } ], - "operationCount": 4, + "operationCount": 5, "triggers": [], "triggerCount": 0, "authType": "none", @@ -4881,6 +4984,10 @@ "name": "List Calls", "description": "Retrieve call data by date range from Gong." }, + { + "name": "Create Call", + "description": "Upload call metadata to Gong and let Gong pull the media from a URL." + }, { "name": "Get Call", "description": "Retrieve detailed data for a specific call from Gong." @@ -4950,7 +5057,7 @@ "description": "Find all references to a phone number in Gong (calls, email messages, meetings, CRM data, and associated contacts)." } ], - "operationCount": 18, + "operationCount": 19, "triggers": [ { "id": "gong_webhook", @@ -5202,7 +5309,7 @@ }, { "name": "Write to Document", - "description": "Write or update content in a Google Docs document" + "description": "Append content to a Google Docs document. Content is inserted literally; Markdown is not interpreted. For formatted output from Markdown, use the Create operation with the markdown toggle enabled." }, { "name": "Create Document", @@ -6642,11 +6749,11 @@ "tags": ["enrichment", "sales-engagement"] }, { - "type": "image_generator", + "type": "image_generator_v2", "slug": "image-generator", "name": "Image Generator", "description": "Generate images", - "longDescription": "Integrate Image Generator into the workflow. Can generate images using DALL-E 3, GPT Image 1, or GPT Image 2.", + "longDescription": "Generate images using OpenAI GPT Image, Google Nano Banana, or Fal.ai image models.", "bgColor": "#4D5FFF", "iconName": "ImageIcon", "docsUrl": "https://docs.sim.ai/tools/image_generator", @@ -6737,6 +6844,10 @@ "name": "List Workflows", "description": "List all workflows in your incident.io workspace." }, + { + "name": "Create Workflow", + "description": "Create a new workflow in incident.io." + }, { "name": "Show Workflow", "description": "Get details of a specific workflow in incident.io." @@ -6853,6 +6964,10 @@ "name": "Create Schedule Override", "description": "Create a new schedule override in incident.io" }, + { + "name": "List Escalation Paths", + "description": "List escalation paths in incident.io" + }, { "name": "Create Escalation Path", "description": "Create a new escalation path in incident.io" @@ -6870,7 +6985,7 @@ "description": "Delete an escalation path in incident.io" } ], - "operationCount": 44, + "operationCount": 46, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -9378,6 +9493,41 @@ "integrationTypes": ["databases", "analytics"], "tags": ["data-warehouse", "data-analytics"] }, + { + "type": "new_relic", + "slug": "new-relic", + "name": "New Relic", + "description": "Query observability data and record deployments in New Relic", + "longDescription": "Integrate New Relic into workflows. Run NRQL queries, search monitored entities, fetch entity details, and record deployment change events.", + "bgColor": "#000000", + "iconName": "NewRelicIcon", + "docsUrl": "https://docs.sim.ai/tools/new_relic", + "operations": [ + { + "name": "Run NRQL Query", + "description": "Run a NRQL query against a New Relic account using NerdGraph." + }, + { + "name": "Search Entities", + "description": "Search New Relic entities by name, GUID, domain type, tags, or reporting state." + }, + { + "name": "Get Entity", + "description": "Fetch a New Relic entity by GUID." + }, + { + "name": "Create Deployment Event", + "description": "Record a deployment change event in New Relic change tracking." + } + ], + "operationCount": 4, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["analytics", "developer-tools"], + "tags": ["monitoring", "data-analytics", "incident-management"] + }, { "type": "notion_v2", "slug": "notion", @@ -10589,6 +10739,77 @@ "integrationTypes": ["design", "ai"], "tags": ["image-generation"] }, + { + "type": "railway", + "slug": "railway", + "name": "Railway", + "description": "Manage Railway projects, deployments, and variables", + "longDescription": "Integrate Railway into workflows to list projects, inspect services and environments, monitor deployments, trigger service deployments, and manage environment variables.", + "bgColor": "#FFFFFF", + "iconName": "RailwayIcon", + "docsUrl": "https://docs.sim.ai/tools/railway", + "operations": [ + { + "name": "List Projects", + "description": "List Railway projects visible to the provided token" + }, + { + "name": "Get Project", + "description": "Get a Railway project with its services and environments" + }, + { + "name": "Create Project", + "description": "Create a Railway project" + }, + { + "name": "Update Project", + "description": "Update a Railway project name or description" + }, + { + "name": "Delete Project", + "description": "Delete a Railway project" + }, + { + "name": "Transfer Project", + "description": "Transfer a Railway project to another workspace" + }, + { + "name": "List Project Members", + "description": "List members for a Railway project" + }, + { + "name": "Create Environment", + "description": "Create a Railway project environment" + }, + { + "name": "Delete Environment", + "description": "Delete a Railway project environment" + }, + { + "name": "List Deployments", + "description": "List deployments for a Railway service in an environment" + }, + { + "name": "Deploy Service", + "description": "Trigger a deployment for a Railway service in an environment" + }, + { + "name": "List Variables", + "description": "List Railway environment variables for a service or shared environment" + }, + { + "name": "Upsert Variable", + "description": "Create or update a Railway environment variable" + } + ], + "operationCount": 13, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["developer-tools"], + "tags": ["cloud", "ci-cd"] + }, { "type": "reddit", "slug": "reddit", @@ -14015,14 +14236,14 @@ "tags": ["cloud", "ci-cd"] }, { - "type": "video_generator_v2", + "type": "video_generator_v3", "slug": "video-generator", "name": "Video Generator", "description": "Generate videos from text using AI", - "longDescription": "Generate high-quality videos from text prompts using leading AI providers. Supports multiple models, aspect ratios, resolutions, and provider-specific features like world consistency, camera controls, and audio generation.", + "longDescription": "Generate high-quality videos from text prompts using leading AI providers. Supports Runway, Google Veo, Luma, MiniMax, and Fal.ai multi-model generation with provider-specific durations, aspect ratios, resolutions, prompt optimization, and native audio controls.", "bgColor": "#181C1E", "iconName": "VideoIcon", - "docsUrl": "https://docs.sim.ai/tools/video-generator", + "docsUrl": "https://docs.sim.ai/tools/video_generator", "operations": [], "operationCount": 0, "triggers": [], @@ -14212,6 +14433,45 @@ "integrationTypes": ["search", "documents"], "tags": ["knowledge-base", "web-scraping"] }, + { + "type": "wiza", + "slug": "wiza", + "name": "Wiza", + "description": "Find, enrich, and verify B2B contact data with Wiza", + "longDescription": "Integrates Wiza into the workflow. Search prospects, enrich companies, reveal verified emails and phone numbers for individuals, and check your account credit balance.", + "bgColor": "#9284BC", + "iconName": "WizaIcon", + "docsUrl": "https://docs.sim.ai/tools/wiza", + "operations": [ + { + "name": "Prospect Search", + "description": "Search Wiza" + }, + { + "name": "Company Enrichment", + "description": "Enrich a company by name, domain, LinkedIn ID, or LinkedIn slug with detailed firmographic data" + }, + { + "name": "Start Individual Reveal", + "description": "Start an individual reveal to enrich a contact via LinkedIn URL, name+company, or email" + }, + { + "name": "Get Individual Reveal", + "description": "Retrieve the status and enriched data for an individual reveal by ID" + }, + { + "name": "Get Credits", + "description": "Retrieve the remaining credits on your Wiza account" + } + ], + "operationCount": 5, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["sales"], + "tags": ["enrichment", "sales-engagement"] + }, { "type": "wordpress", "slug": "wordpress", diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 15fb1c30cf3..a80dc572e4d 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -36,7 +36,6 @@ --shadow-overlay: 0 16px 48px rgba(0, 0, 0, 0.15); --shadow-kbd: 0 4px 0 0 rgba(48, 48, 48, 1); --shadow-kbd-sm: 0 2px 0 0 rgba(48, 48, 48, 1); - --shadow-brand-inset: inset 0 1.25px 2.5px 0 #9b77ff; --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04); } @@ -218,6 +217,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { --border-success: #e0e0e0; /* Brand & state */ + --brand-agent: #6f3dfa; --brand-secondary: #33b4ff; --brand-accent: #33c482; --brand-accent-hover: #2dac72; @@ -374,6 +374,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { --border-success: #575757; /* Brand & state */ + --brand-agent: #701ffc; --brand-secondary: #33b4ff; --brand-accent: #33c482; --brand-accent-hover: #2dac72; @@ -762,12 +763,8 @@ input[type="search"]::-ms-clear { --card-text: 0 0% 3.9%; --card-hover: 0 0% 96.1%; --base-muted-foreground: #737373; - --gradient-primary: 263 85% 70%; - --gradient-secondary: 336 95% 65%; - --brand: #6f3dfa; - --brand-hover: #6338d9; - --brand-link: #6f3dfa; - --brand-link-hover: #6f3dfa; + --brand: #33c482; + --brand-hover: #2dac72; } .dark { @@ -799,12 +796,8 @@ input[type="search"]::-ms-clear { --card-text: 0 0% 98%; --card-hover: 0 0% 12.0%; --base-muted-foreground: #a3a3a3; - --gradient-primary: 263 90% 75%; - --gradient-secondary: 336 100% 72%; - --brand: #701ffc; - --brand-hover: #802fff; - --brand-link: #9d54ff; - --brand-link-hover: #a66fff; + --brand: #33c482; + --brand-hover: #2dac72; } } diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 547a164b069..a860df8eb28 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -26,7 +26,6 @@ const { mockDbUpdate, mockSendEmail, mockRenderOTPEmail, - mockAddCorsHeaders, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, @@ -50,7 +49,6 @@ const { const mockDbUpdate = vi.fn() const mockSendEmail = vi.fn() const mockRenderOTPEmail = vi.fn() - const mockAddCorsHeaders = vi.fn() const mockSetChatAuthCookie = vi.fn() const mockGetStorageMethod = vi.fn() const mockZodParse = vi.fn() @@ -69,7 +67,6 @@ const { mockDbUpdate, mockSendEmail, mockRenderOTPEmail, - mockAddCorsHeaders, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, @@ -131,7 +128,6 @@ vi.mock('@/components/emails', () => ({ })) vi.mock('@/lib/core/security/deployment', () => ({ - addCorsHeaders: mockAddCorsHeaders, isEmailAllowed: (email: string, allowedEmails: string[]) => { if (allowedEmails.includes(email)) return true const atIndex = email.indexOf('@') @@ -248,7 +244,6 @@ describe('Chat OTP API Route', () => { mockSendEmail.mockResolvedValue({ success: true }) mockRenderOTPEmail.mockResolvedValue('OTP Email') - mockAddCorsHeaders.mockImplementation((response: unknown) => response) mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ json: () => Promise.resolve(data), status: 200, diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index fcccc003e86..aeb1b69f450 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -7,7 +7,7 @@ import { renderOTPEmail } from '@/components/emails' import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' -import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' +import { isEmailAllowed } from '@/lib/core/security/deployment' import { decodeOTPValue, deleteOTP, @@ -47,15 +47,12 @@ export const POST = withRouteHandler( ) const response = createErrorResponse('Too many requests. Please try again later.', 429) response.headers.set('Retry-After', String(retryAfter)) - return addCorsHeaders(response, request) + return response } const parsed = await parseRequest(requestChatEmailOtpContract, request, context, { validationErrorResponse: (error) => - addCorsHeaders( - createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), - request - ), + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), }) if (!parsed.success) return parsed.response const { email } = parsed.data.body @@ -75,16 +72,13 @@ export const POST = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] if (deployment.authType !== 'email') { - return addCorsHeaders( - createErrorResponse('This chat does not use email authentication', 400), - request - ) + return createErrorResponse('This chat does not use email authentication', 400) } const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) @@ -92,10 +86,7 @@ export const POST = withRouteHandler( : [] if (!isEmailAllowed(email, allowedEmails)) { - return addCorsHeaders( - createErrorResponse('Email not authorized for this chat', 403), - request - ) + return createErrorResponse('Email not authorized for this chat', 403) } const emailRateLimit = await rateLimiter.checkRateLimitDirect( @@ -114,7 +105,7 @@ export const POST = withRouteHandler( 429 ) response.headers.set('Retry-After', String(retryAfter)) - return addCorsHeaders(response, request) + return response } const otp = generateOTP() @@ -135,17 +126,14 @@ export const POST = withRouteHandler( if (!emailResult.success) { logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return addCorsHeaders( - createErrorResponse('Failed to send verification email', 500), - request - ) + return createErrorResponse('Failed to send verification email', 500) } logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`) - return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request) + return createSuccessResponse({ message: 'Verification code sent' }) } catch (error) { logger.error(`[${requestId}] Error processing OTP request:`, error) - return addCorsHeaders(createErrorResponse('Failed to process request', 500), request) + return createErrorResponse('Failed to process request', 500) } } ) @@ -158,10 +146,7 @@ export const PUT = withRouteHandler( try { const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, { validationErrorResponse: (error) => - addCorsHeaders( - createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), - request - ), + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), }) if (!parsed.success) return parsed.response const { email, otp } = parsed.data.body @@ -184,17 +169,14 @@ export const PUT = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] const storedValue = await getOTP('chat', deployment.id, email) if (!storedValue) { - return addCorsHeaders( - createErrorResponse('No verification code found, request a new one', 400), - request - ) + return createErrorResponse('No verification code found, request a new one', 400) } const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) @@ -202,43 +184,34 @@ export const PUT = withRouteHandler( if (attempts >= MAX_OTP_ATTEMPTS) { await deleteOTP('chat', deployment.id, email) logger.warn(`[${requestId}] OTP already locked out for ${email}`) - return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), - request - ) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) } if (storedOTP !== otp) { const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue) if (result === 'locked') { logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`) - return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), - request - ) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) } - return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request) + return createErrorResponse('Invalid verification code', 400) } await deleteOTP('chat', deployment.id, email) - const response = addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - outputConfigs: deployment.outputConfigs, - }), - request - ) + const response = createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + outputConfigs: deployment.outputConfigs, + }) setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) return response } catch (error) { logger.error(`[${requestId}] Error verifying OTP:`, error) - return addCorsHeaders(createErrorResponse('Failed to process request', 500), request) + return createErrorResponse('Failed to process request', 500) } } ) diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 426545aece0..b1b36d60b6d 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -63,13 +63,11 @@ const createMockStream = () => { }) } -const { mockAddCorsHeaders, mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } = - vi.hoisted(() => ({ - mockAddCorsHeaders: vi.fn().mockImplementation((response: Response) => response), - mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), - mockSetChatAuthCookie: vi.fn(), - mockValidateAuthToken: vi.fn().mockReturnValue(false), - })) +const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } = vi.hoisted(() => ({ + mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), + mockSetChatAuthCookie: vi.fn(), + mockValidateAuthToken: vi.fn().mockReturnValue(false), +})) const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -81,7 +79,6 @@ vi.mock('@sim/db', () => ({ })) vi.mock('@/lib/core/security/deployment', () => ({ - addCorsHeaders: mockAddCorsHeaders, validateAuthToken: mockValidateAuthToken, setDeploymentAuthCookie: vi.fn(), isEmailAllowed: vi.fn().mockReturnValue(false), @@ -181,7 +178,6 @@ describe('Chat Identifier API Route', () => { }, }) - mockAddCorsHeaders.mockImplementation((response: Response) => response) mockValidateChatAuth.mockResolvedValue({ authorized: true }) mockValidateAuthToken.mockReturnValue(false) mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => { diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index f35d950a21c..7a4a12d5754 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -6,7 +6,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deployedChatPostContract } from '@/lib/api/contracts/chats' import { parseRequest } from '@/lib/api/server' -import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' +import { validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -49,13 +49,9 @@ export const POST = withRouteHandler( const parsed = await parseRequest(deployedChatPostContract, request, context, { validationErrorResponse: (err) => { const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ') - return addCorsHeaders( - createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR'), - request - ) + return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR') }, - invalidJsonResponse: () => - addCorsHeaders(createErrorResponse('Invalid request body', 400), request), + invalidJsonResponse: () => createErrorResponse('Invalid request body', 400), }) if (!parsed.success) return parsed.response const parsedBody = parsed.data.body @@ -80,7 +76,7 @@ export const POST = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] @@ -99,10 +95,7 @@ export const POST = withRouteHandler( logger.warn( `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` ) - return addCorsHeaders( - createErrorResponse('This chat is currently unavailable', 403), - request - ) + return createErrorResponse('This chat is currently unavailable', 403) } const executionId = generateId() @@ -127,27 +120,18 @@ export const POST = withRouteHandler( traceSpans: [], }) - return addCorsHeaders( - createErrorResponse('This chat is currently unavailable', 403), - request - ) + return createErrorResponse('This chat is currently unavailable', 403) } const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) + return createErrorResponse(authResult.error || 'Authentication required', 401) } const { input, password, email, conversationId, files } = parsedBody if ((password || email) && !input) { - const response = addCorsHeaders( - createSuccessResponse(toChatConfigResponse(deployment)), - request - ) + const response = createSuccessResponse(toChatConfigResponse(deployment)) if (deployment.authType !== 'sso') { setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) @@ -157,7 +141,7 @@ export const POST = withRouteHandler( } if (!input && (!files || files.length === 0)) { - return addCorsHeaders(createErrorResponse('No input provided', 400), request) + return createErrorResponse('No input provided', 400) } const executionId = generateId() @@ -182,12 +166,9 @@ export const POST = withRouteHandler( if (!preprocessResult.success) { logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) - return addCorsHeaders( - createErrorResponse( - preprocessResult.error?.message || 'Failed to process request', - preprocessResult.error?.statusCode || 500 - ), - request + return createErrorResponse( + preprocessResult.error?.message || 'Failed to process request', + preprocessResult.error?.statusCode || 500 ) } @@ -196,10 +177,7 @@ export const POST = withRouteHandler( const workspaceId = workflowRecord?.workspaceId if (!workspaceId) { logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) - return addCorsHeaders( - createErrorResponse('Workflow has no associated workspace', 500), - request - ) + return createErrorResponse('Workflow has no associated workspace', 500) } try { @@ -302,20 +280,14 @@ export const POST = withRouteHandler( status: 200, headers: SSE_HEADERS, }) - return addCorsHeaders(streamResponse, request) + return streamResponse } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process request', 500) } } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process request', 500) } } ) @@ -345,17 +317,14 @@ export const GET = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { logger.warn(`[${requestId}] Chat is not active: ${identifier}`) - return addCorsHeaders( - createErrorResponse('This chat is currently unavailable', 403), - request - ) + return createErrorResponse('This chat is currently unavailable', 403) } const cookieName = `chat_auth_${deployment.id}` @@ -367,7 +336,7 @@ export const GET = withRouteHandler( authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password) ) { - return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request) + return createSuccessResponse(toChatConfigResponse(deployment)) } const authResult = await validateChatAuth(requestId, deployment, request) @@ -375,19 +344,13 @@ export const GET = withRouteHandler( logger.info( `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}` ) - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) + return createErrorResponse(authResult.error || 'Authentication required', 401) } - return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request) + return createSuccessResponse(toChatConfigResponse(deployment)) } catch (error: any) { logger.error(`[${requestId}] Error fetching chat info:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to fetch chat information', 500), - request - ) + return createErrorResponse(error.message || 'Failed to fetch chat information', 500) } } ) diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts index c6878876a90..c6ab98cfe94 100644 --- a/apps/sim/app/api/chat/[identifier]/sso/route.ts +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -7,7 +7,7 @@ import { chatSSOContract } from '@/lib/api/contracts/chats' import { parseRequest } from '@/lib/api/server' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' -import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' +import { isEmailAllowed } from '@/lib/core/security/deployment' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -41,7 +41,7 @@ export const POST = withRouteHandler( ) const response = createErrorResponse('Too many requests. Please try again later.', 429) response.headers.set('Retry-After', String(retryAfter)) - return addCorsHeaders(response, request) + return response } const parsed = await parseRequest(chatSSOContract, request, context) @@ -62,18 +62,15 @@ export const POST = withRouteHandler( if (!deployment || !deployment.isActive) { logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } if (deployment.authType !== 'sso') { - return addCorsHeaders( - createErrorResponse('Chat is not configured for SSO authentication', 400), - request - ) + return createErrorResponse('Chat is not configured for SSO authentication', 400) } const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || []) - return addCorsHeaders(createSuccessResponse({ eligible }), request) + return createSuccessResponse({ eligible }) } ) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 60395c0bbd1..86e46340a92 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -17,7 +17,6 @@ const { mockMergeSubBlockValues, mockValidateAuthToken, mockSetDeploymentAuthCookie, - mockAddCorsHeaders, mockIsEmailAllowed, mockGetSession, } = vi.hoisted(() => ({ @@ -25,7 +24,6 @@ const { mockMergeSubBlockValues: vi.fn().mockReturnValue({}), mockValidateAuthToken: vi.fn().mockReturnValue(false), mockSetDeploymentAuthCookie: vi.fn(), - mockAddCorsHeaders: vi.fn((response: unknown) => response), mockIsEmailAllowed: vi.fn(), mockGetSession: vi.fn(), })) @@ -57,7 +55,6 @@ vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: mockValidateAuthToken, setDeploymentAuthCookie: mockSetDeploymentAuthCookie, - addCorsHeaders: mockAddCorsHeaders, isEmailAllowed: mockIsEmailAllowed, })) diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index df3bad6170f..eed07748581 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -91,7 +91,7 @@ vi.mock('fs/promises', () => ({ })) import { createMockRequest } from '@sim/testing' -import { OPTIONS, POST } from '@/app/api/files/delete/route' +import { POST } from '@/app/api/files/delete/route' describe('File Delete API Route', () => { beforeEach(() => { @@ -198,12 +198,4 @@ describe('File Delete API Route', () => { expect(data).toHaveProperty('error', 'InvalidRequestError') expect(data).toHaveProperty('message', 'No file path provided') }) - - it('should handle CORS preflight requests', async () => { - const response = await OPTIONS() - - expect(response.status).toBe(204) - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS') - expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type') - }) }) diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 8e0bbb7ec5b..4eeeb538747 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -12,7 +12,6 @@ import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, - createOptionsResponse, createSuccessResponse, extractFilename, FileNotFoundError, @@ -119,10 +118,3 @@ function extractStorageKeyFromPath(filePath: string): string { return extractFilename(filePath) } - -/** - * Handle CORS preflight requests - */ -export const OPTIONS = withRouteHandler(async () => { - return createOptionsResponse() -}) diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index 014a7d4cfd7..ac5015c9a7d 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -156,17 +156,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } }) - -export const OPTIONS = withRouteHandler(async () => { - return NextResponse.json( - {}, - { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }, - } - ) -}) diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 724aab5d065..9abfa5be2d4 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -107,7 +107,7 @@ vi.mock('@/lib/uploads', () => ({ isUsingCloudStorage: mockIsUsingCloudStorageUploads, })) -import { OPTIONS, POST } from '@/app/api/files/presigned/route' +import { POST } from '@/app/api/files/presigned/route' const defaultMockUser = { id: 'test-user-id', @@ -827,16 +827,4 @@ describe('/api/files/presigned', () => { expect(mockValidateAttachmentFileType).not.toHaveBeenCalled() }) }) - - describe('OPTIONS', () => { - it('should handle CORS preflight requests', async () => { - const response = await OPTIONS() - - expect(response.status).toBe(200) - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('POST, OPTIONS') - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type, Authorization' - ) - }) - }) }) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 7484712978c..3312434f04d 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -310,17 +310,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } }) - -export const OPTIONS = withRouteHandler(async () => { - return NextResponse.json( - {}, - { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }, - } - ) -}) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index f0ef4ede98b..cf80cbf9b0d 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -95,7 +95,7 @@ vi.mock('@/lib/uploads/setup.server', () => ({ })) import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { OPTIONS, POST } from '@/app/api/files/upload/route' +import { POST } from '@/app/api/files/upload/route' /** * Configure mocks for authenticated file upload tests @@ -307,14 +307,6 @@ describe('File Upload API Route', () => { expect(data).toHaveProperty('error') expect(typeof data.error).toBe('string') }) - - it('should handle CORS preflight requests', async () => { - const response = await OPTIONS() - - expect(response.status).toBe(204) - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS') - expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type') - }) }) describe('File Upload Security Tests', () => { diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 2bdd3c81d18..e1dc599cad7 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -21,11 +21,7 @@ import { validateFileType, } from '@/lib/uploads/utils/validation' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - createErrorResponse, - createOptionsResponse, - InvalidRequestError, -} from '@/app/api/files/utils' +import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils' const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) @@ -430,7 +426,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createErrorResponse(error instanceof Error ? error : new Error('File upload failed')) } }) - -export const OPTIONS = withRouteHandler(async () => { - return createOptionsResponse() -}) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index ac759b45bcc..b6b05f4cbb6 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -238,13 +238,3 @@ export function createErrorResponse(error: Error, status = 500): NextResponse { export function createSuccessResponse(data: ApiSuccessResponse): NextResponse { return NextResponse.json(data) } - -export function createOptionsResponse(): NextResponse { - return new NextResponse(null, { - status: 204, - headers: { - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }) -} diff --git a/apps/sim/app/api/form/[identifier]/otp/route.test.ts b/apps/sim/app/api/form/[identifier]/otp/route.test.ts index 4b3b13441d0..5a0a9eb1033 100644 --- a/apps/sim/app/api/form/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/form/[identifier]/otp/route.test.ts @@ -26,7 +26,6 @@ const { mockDbUpdate, mockSendEmail, mockRenderOTPEmail, - mockAddCorsHeaders, mockSetFormAuthCookie, mockGetStorageMethod, mockZodParse, @@ -57,7 +56,6 @@ const { mockDbUpdate: vi.fn(), mockSendEmail: vi.fn(), mockRenderOTPEmail: vi.fn(), - mockAddCorsHeaders: vi.fn(), mockSetFormAuthCookie: vi.fn(), mockGetStorageMethod: vi.fn(), mockZodParse: vi.fn(), @@ -119,7 +117,6 @@ vi.mock('@/components/emails', () => ({ })) vi.mock('@/lib/core/security/deployment', () => ({ - addCorsHeaders: mockAddCorsHeaders, isEmailAllowed: (email: string, allowedEmails: string[]) => { if (allowedEmails.includes(email)) return true const atIndex = email.indexOf('@') @@ -253,7 +250,6 @@ describe('Form OTP API Route', () => { mockSendEmail.mockResolvedValue({ success: true }) mockRenderOTPEmail.mockResolvedValue('OTP Email') - mockAddCorsHeaders.mockImplementation((response: unknown) => response) mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ json: () => Promise.resolve(data), status: 200, diff --git a/apps/sim/app/api/form/[identifier]/otp/route.ts b/apps/sim/app/api/form/[identifier]/otp/route.ts index 0d9804efa55..55f3f493ca0 100644 --- a/apps/sim/app/api/form/[identifier]/otp/route.ts +++ b/apps/sim/app/api/form/[identifier]/otp/route.ts @@ -7,7 +7,7 @@ import { renderOTPEmail } from '@/components/emails' import { requestFormEmailOtpContract, verifyFormEmailOtpContract } from '@/lib/api/contracts/forms' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' -import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' +import { isEmailAllowed } from '@/lib/core/security/deployment' import { decodeOTPValue, deleteOTP, @@ -47,15 +47,12 @@ export const POST = withRouteHandler( ) const response = createErrorResponse('Too many requests. Please try again later.', 429) response.headers.set('Retry-After', String(retryAfter)) - return addCorsHeaders(response, request) + return response } const parsed = await parseRequest(requestFormEmailOtpContract, request, context, { validationErrorResponse: (error) => - addCorsHeaders( - createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), - request - ), + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), }) if (!parsed.success) return parsed.response const { email } = parsed.data.body @@ -74,23 +71,17 @@ export const POST = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) + return createErrorResponse('Form not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } if (deployment.authType !== 'email') { - return addCorsHeaders( - createErrorResponse('This form does not use email authentication', 400), - request - ) + return createErrorResponse('This form does not use email authentication', 400) } const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) @@ -98,10 +89,7 @@ export const POST = withRouteHandler( : [] if (!isEmailAllowed(email, allowedEmails)) { - return addCorsHeaders( - createErrorResponse('Email not authorized for this form', 403), - request - ) + return createErrorResponse('Email not authorized for this form', 403) } const emailRateLimit = await rateLimiter.checkRateLimitDirect( @@ -120,7 +108,7 @@ export const POST = withRouteHandler( 429 ) response.headers.set('Retry-After', String(retryAfter)) - return addCorsHeaders(response, request) + return response } const otp = generateOTP() @@ -141,17 +129,14 @@ export const POST = withRouteHandler( if (!emailResult.success) { logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return addCorsHeaders( - createErrorResponse('Failed to send verification email', 500), - request - ) + return createErrorResponse('Failed to send verification email', 500) } logger.info(`[${requestId}] OTP sent to ${email} for form ${deployment.id}`) - return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request) + return createSuccessResponse({ message: 'Verification code sent' }) } catch (error) { logger.error(`[${requestId}] Error processing OTP request:`, error) - return addCorsHeaders(createErrorResponse('Failed to process request', 500), request) + return createErrorResponse('Failed to process request', 500) } } ) @@ -164,10 +149,7 @@ export const PUT = withRouteHandler( try { const parsed = await parseRequest(verifyFormEmailOtpContract, request, context, { validationErrorResponse: (error) => - addCorsHeaders( - createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), - request - ), + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), }) if (!parsed.success) return parsed.response const { email, otp } = parsed.data.body @@ -186,23 +168,17 @@ export const PUT = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) + return createErrorResponse('Form not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } if (deployment.authType !== 'email') { - return addCorsHeaders( - createErrorResponse('This form does not use email authentication', 400), - request - ) + return createErrorResponse('This form does not use email authentication', 400) } const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) @@ -210,18 +186,12 @@ export const PUT = withRouteHandler( : [] if (!isEmailAllowed(email, allowedEmails)) { - return addCorsHeaders( - createErrorResponse('Email not authorized for this form', 403), - request - ) + return createErrorResponse('Email not authorized for this form', 403) } const storedValue = await getOTP('form', deployment.id, email) if (!storedValue) { - return addCorsHeaders( - createErrorResponse('No verification code found, request a new one', 400), - request - ) + return createErrorResponse('No verification code found, request a new one', 400) } const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) @@ -229,33 +199,27 @@ export const PUT = withRouteHandler( if (attempts >= MAX_OTP_ATTEMPTS) { await deleteOTP('form', deployment.id, email) logger.warn(`[${requestId}] OTP already locked out for ${email}`) - return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), - request - ) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) } if (storedOTP !== otp) { const result = await incrementOTPAttempts('form', deployment.id, email, storedValue) if (result === 'locked') { logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`) - return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), - request - ) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) } - return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request) + return createErrorResponse('Invalid verification code', 400) } await deleteOTP('form', deployment.id, email) - const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + const response = createSuccessResponse({ authenticated: true }) setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password) return response } catch (error) { logger.error(`[${requestId}] Error verifying OTP:`, error) - return addCorsHeaders(createErrorResponse('Failed to process request', 500), request) + return createErrorResponse('Failed to process request', 500) } } ) diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index d5ed51c4af7..46b0f1e068f 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -6,7 +6,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { formSubmitBodySchema } from '@/lib/api/contracts/forms' import { parseJsonBody } from '@/lib/api/server' -import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' +import { validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -55,7 +55,7 @@ export const POST = withRouteHandler( try { const parsedJson = await parseJsonBody(request) if (!parsedJson.success) { - return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) + return createErrorResponse('Invalid request body', 400) } const bodyValidation = formSubmitBodySchema.safeParse(parsedJson.data) @@ -64,10 +64,7 @@ export const POST = withRouteHandler( .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') logger.warn(`[${requestId}] Validation error: ${errorMessage}`) - return addCorsHeaders( - createErrorResponse(`Invalid request body: ${errorMessage}`, 400), - request - ) + return createErrorResponse(`Invalid request body: ${errorMessage}`, 400) } const parsedBody = bodyValidation.data @@ -89,7 +86,7 @@ export const POST = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) + return createErrorResponse('Form not found', 404) } const deployment = deploymentResult[0] @@ -108,10 +105,7 @@ export const POST = withRouteHandler( logger.warn( `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` ) - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } const executionId = generateId() @@ -136,31 +130,25 @@ export const POST = withRouteHandler( traceSpans: [], }) - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } const authResult = await validateFormAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) + return createErrorResponse(authResult.error || 'Authentication required', 401) } const { formData, password, email } = parsedBody // If only authentication credentials provided (no form data), just return authenticated if ((password || email) && !formData) { - const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + const response = createSuccessResponse({ authenticated: true }) setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password) return response } if (!formData || Object.keys(formData).length === 0) { - return addCorsHeaders(createErrorResponse('No form data provided', 400), request) + return createErrorResponse('No form data provided', 400) } const executionId = generateId() @@ -184,12 +172,9 @@ export const POST = withRouteHandler( if (!preprocessResult.success) { logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) - return addCorsHeaders( - createErrorResponse( - preprocessResult.error?.message || 'Failed to process request', - preprocessResult.error?.statusCode || 500 - ), - request + return createErrorResponse( + preprocessResult.error?.message || 'Failed to process request', + preprocessResult.error?.statusCode || 500 ) } @@ -198,10 +183,7 @@ export const POST = withRouteHandler( const workspaceId = workflowRecord?.workspaceId if (!workspaceId) { logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) - return addCorsHeaders( - createErrorResponse('Workflow has no associated workspace', 500), - request - ) + return createErrorResponse('Workflow has no associated workspace', 500) } try { @@ -264,29 +246,20 @@ export const POST = withRouteHandler( // Return success with customizations for thank you screen const customizations = deployment.customizations as Record | null - return addCorsHeaders( - createSuccessResponse({ - success: true, - executionId, - thankYouTitle: customizations?.thankYouTitle || 'Thank you!', - thankYouMessage: - customizations?.thankYouMessage || 'Your response has been submitted successfully.', - }), - request - ) + return createSuccessResponse({ + success: true, + executionId, + thankYouTitle: customizations?.thankYouTitle || 'Thank you!', + thankYouMessage: + customizations?.thankYouMessage || 'Your response has been submitted successfully.', + }) } catch (error: any) { logger.error(`[${requestId}] Error processing form submission:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process form submission', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process form submission', 500) } } catch (error: any) { logger.error(`[${requestId}] Error processing form submission:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process form submission', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process form submission', 500) } } ) @@ -316,17 +289,14 @@ export const GET = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) + return createErrorResponse('Form not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { logger.warn(`[${requestId}] Form is not active: ${identifier}`) - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } // Get the workflow's input schema @@ -341,18 +311,15 @@ export const GET = withRouteHandler( authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password) ) { - return addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - showBranding: deployment.showBranding, - inputSchema, - }), - request - ) + return createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + showBranding: deployment.showBranding, + inputSchema, + }) } // Check authentication requirement @@ -362,46 +329,33 @@ export const GET = withRouteHandler( logger.info( `[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}` ) - return addCorsHeaders( - NextResponse.json( - { - success: false, - error: authResult.error || 'Authentication required', - authType: deployment.authType, - title: deployment.title, - customizations: { - primaryColor: (deployment.customizations as any)?.primaryColor, - logoUrl: (deployment.customizations as any)?.logoUrl, - }, + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + authType: deployment.authType, + title: deployment.title, + customizations: { + primaryColor: (deployment.customizations as any)?.primaryColor, + logoUrl: (deployment.customizations as any)?.logoUrl, }, - { status: 401 } - ), - request + }, + { status: 401 } ) } - return addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - showBranding: deployment.showBranding, - inputSchema, - }), - request - ) + return createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + showBranding: deployment.showBranding, + inputSchema, + }) } catch (error: any) { logger.error(`[${requestId}] Error fetching form info:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to fetch form information', 500), - request - ) + return createErrorResponse(error.message || 'Failed to fetch form information', 500) } } ) - -export const OPTIONS = withRouteHandler(async (request: NextRequest) => { - return addCorsHeaders(new NextResponse(null, { status: 204 }), request) -}) diff --git a/apps/sim/app/api/form/utils.test.ts b/apps/sim/app/api/form/utils.test.ts index 1826d9386c1..d6b51c2d778 100644 --- a/apps/sim/app/api/form/utils.test.ts +++ b/apps/sim/app/api/form/utils.test.ts @@ -7,17 +7,13 @@ import { encryptionMock, encryptionMockFns, workflowsUtilsMock } from '@sim/test import type { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockValidateAuthToken, - mockSetDeploymentAuthCookie, - mockAddCorsHeaders, - mockIsEmailAllowed, -} = vi.hoisted(() => ({ - mockValidateAuthToken: vi.fn().mockReturnValue(false), - mockSetDeploymentAuthCookie: vi.fn(), - mockAddCorsHeaders: vi.fn((response: unknown) => response), - mockIsEmailAllowed: vi.fn(), -})) +const { mockValidateAuthToken, mockSetDeploymentAuthCookie, mockIsEmailAllowed } = vi.hoisted( + () => ({ + mockValidateAuthToken: vi.fn().mockReturnValue(false), + mockSetDeploymentAuthCookie: vi.fn(), + mockIsEmailAllowed: vi.fn(), + }) +) const mockDecryptSecret = encryptionMockFns.mockDecryptSecret @@ -26,7 +22,6 @@ vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: mockValidateAuthToken, setDeploymentAuthCookie: mockSetDeploymentAuthCookie, - addCorsHeaders: mockAddCorsHeaders, isEmailAllowed: mockIsEmailAllowed, })) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index fe2ccc59b1a..3b191146c48 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -12,9 +12,10 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExecuteInE2B, mockExecuteInIsolatedVM } = vi.hoisted(() => ({ +const { mockExecuteInE2B, mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({ mockExecuteInE2B: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), + mockUploadFile: vi.fn(), })) vi.mock('@/lib/execution/isolated-vm', () => ({ @@ -42,16 +43,26 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ uploadWorkspaceFile: vi.fn(), })) +vi.mock('@/lib/uploads', () => ({ + StorageService: { + uploadFile: mockUploadFile, + }, +})) + vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) import { validateProxyUrl } from '@/lib/core/security/input-validation' +import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' +import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { POST } from '@/app/api/function/execute/route' describe('Function Execute API Route', () => { beforeEach(() => { vi.clearAllMocks() + featureFlagsMock.isE2bEnabled = false hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, @@ -60,6 +71,8 @@ describe('Function Execute API Route', () => { }) mockExecuteInIsolatedVM.mockResolvedValue({ result: 'test', stdout: '' }) + mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) + clearLargeValueCacheForTests() mockExecuteInE2B.mockResolvedValue({ result: 'e2b success', @@ -201,6 +214,60 @@ describe('Function Execute API Route', () => { expect(data.output).toHaveProperty('executionTime') }) + it('compacts large array result fields to manifests when execution context is durable', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: { + rows: Array.from({ length: 120_000 }, (_, index) => ({ + key: `SIM-${index}`, + payload: 'x'.repeat(100), + })), + }, + stdout: '', + }) + + const req = createMockRequest('POST', { + code: 'return rows', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(isLargeArrayManifest(data.output.result.rows)).toBe(true) + expect(data.output.result.rows).toMatchObject({ + __simLargeArrayManifest: true, + kind: 'array', + totalCount: 120_000, + }) + }) + + it('keeps large string result fields as generic large value refs', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: { + text: 'x'.repeat(9 * 1024 * 1024), + }, + stdout: '', + }) + + const req = createMockRequest('POST', { + code: 'return text', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(isLargeValueRef(data.output.result.text)).toBe(true) + }) + it('should return computed result for multi-line code', async () => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' }) @@ -240,6 +307,73 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) }) + + it('rejects large refs in runtimes without ref-native helpers', async () => { + featureFlagsMock.isE2bEnabled = true + const req = createMockRequest('POST', { + code: 'echo "$__blockRef_0"', + language: 'shell', + contextVariables: { + __blockRef_0: { + __simLargeValueRef: true, + version: 1, + id: 'lv_ABCDEFGHIJKL', + kind: 'array', + size: 12 * 1024 * 1024, + executionId: 'execution-1', + }, + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain( + 'Large execution values require the JavaScript isolated-vm runtime' + ) + }) + + it('registers manifest array read broker for isolated-vm execution', async () => { + const req = createMockRequest('POST', { + code: 'return await sim.values.readArray(__blockRef_0)', + language: 'javascript', + contextVariables: { + __blockRef_0: { + __simLargeArrayManifest: true, + version: 2, + kind: 'array', + totalCount: 1, + chunkCount: 1, + byteSize: 16, + chunks: [ + { + ref: { + __simLargeValueRef: true, + version: 1, + id: 'lv_ABCDEFGHIJKL', + kind: 'array', + size: 16, + executionId: 'execution-1', + }, + count: 1, + byteSize: 16, + }, + ], + preview: [{ id: 1 }], + }, + }, + }) + + const response = await POST(req) + const data = await response.json() + const [, options] = mockExecuteInIsolatedVM.mock.calls.at(-1) ?? [] + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(options?.brokers).toHaveProperty('sim.values.readArray') + }) }) describe('Template Variable Resolution', () => { diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 1b2d5f844e1..5fa058736d2 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -14,7 +14,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' -import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys' +import { + isLargeArrayManifest, + materializeLargeArrayManifest, +} from '@/lib/execution/payloads/large-array-manifest' +import { containsLargeValueRef, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { MAX_FUNCTION_INLINE_BYTES, MAX_INLINE_MATERIALIZATION_BYTES, @@ -699,6 +704,8 @@ interface FunctionRouteExecutionContext { workspaceId?: string executionId?: string largeValueExecutionIds?: string[] + largeValueKeys?: string[] + fileKeys?: string[] allowLargeValueWorkflowScope?: boolean userId?: string requestId: string @@ -741,17 +748,26 @@ function getBrokerFileArgs(args: unknown): { function createFunctionRuntimeBrokers( context: FunctionRouteExecutionContext ): Record { + context.largeValueKeys ??= [] + context.fileKeys ??= [] + const largeValueKeys = context.largeValueKeys + const fileKeys = context.fileKeys const base = { requestId: context.requestId, workflowId: context.workflowId, workspaceId: context.workspaceId, executionId: context.executionId, largeValueExecutionIds: context.largeValueExecutionIds, + largeValueKeys, + fileKeys, allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope, userId: context.userId, logger, } + const recordMaterializedKeys = (value: unknown) => + recordMaterializedAccessKeys({ largeValueKeys, fileKeys }, value) + const readFile = async (args: unknown, encoding: 'base64' | 'text', chunked = false) => { const fileArgs = getBrokerFileArgs(args) return readUserFileContent(fileArgs.file, { @@ -786,6 +802,24 @@ function createFunctionRuntimeBrokers( if (value === undefined) { throw unavailableLargeValueError(ref) } + recordMaterializedKeys(value) + return value + }, + 'sim.values.readArray': async (args) => { + const record = asRecord(args) + const options = asRecord(record.options) + const manifest = record.ref + if (!isLargeArrayManifest(manifest)) { + throw new Error('Expected a large array manifest.') + } + if (!context.executionId) { + throw new Error('Large array manifests require an execution context.') + } + const value = await materializeLargeArrayManifest(manifest, { + ...base, + maxBytes: clampInlineBytes(options.maxBytes, MAX_INLINE_MATERIALIZATION_BYTES), + }) + recordMaterializedKeys(value) return value }, } @@ -810,7 +844,17 @@ async function functionJsonResponse( context: FunctionRouteExecutionContext, init?: ResponseInit ) { - return NextResponse.json(await compactFunctionRouteBody(body, context), init) + return NextResponse.json( + await compactFunctionRouteBody( + { + ...body, + largeValueKeys: context.largeValueKeys, + fileKeys: context.fileKeys, + }, + context + ), + init + ) } async function maybeExportSandboxFileToWorkspace(args: { @@ -955,6 +999,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, largeValueExecutionIds, + largeValueKeys, + fileKeys, allowLargeValueWorkflowScope = false, workspaceId, isCustomTool = false, @@ -979,6 +1025,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workspaceId, executionId, largeValueExecutionIds, + largeValueKeys, + fileKeys, allowLargeValueWorkflowScope, userId: auth.userId, requestId, @@ -1013,6 +1061,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { contextVariables = { ...codeResolution.contextVariables, ...preResolvedContextVariables } } + if (lang === CodeLanguage.Shell && containsLargeValueRef(contextVariables)) { + throw new Error( + 'Large execution values require the JavaScript isolated-vm runtime. Select a nested field or read the value in a JavaScript function.' + ) + } + let jsImports = '' let jsRemainingCode = resolvedCode let hasImports = false @@ -1124,6 +1178,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { !isCustomTool && (lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports)) + if (useE2B && containsLargeValueRef(contextVariables)) { + throw new Error( + 'Large execution values require the JavaScript isolated-vm runtime. Remove imports, select a nested field, or read the value in a JavaScript function without E2B.' + ) + } + if (useE2B) { logger.info(`[${requestId}] E2B status`, { enabled: isE2bEnabled, diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 694009c94c0..9cdb7de7301 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -386,19 +386,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } }) -export const OPTIONS = withRouteHandler(async () => { - return new NextResponse(null, { - status: 204, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE', - 'Access-Control-Allow-Headers': - 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept', - 'Access-Control-Max-Age': '86400', - }, - }) -}) - export const DELETE = withRouteHandler(async (request: NextRequest) => { void request return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 }) diff --git a/apps/sim/app/api/templates/approved/sanitized/route.ts b/apps/sim/app/api/templates/approved/sanitized/route.ts index a2eba630a50..6fa0e69d7f5 100644 --- a/apps/sim/app/api/templates/approved/sanitized/route.ts +++ b/apps/sim/app/api/templates/approved/sanitized/route.ts @@ -131,21 +131,3 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } }) - -// Add a helpful OPTIONS handler for CORS preflight -export const OPTIONS = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const queryValidation = noInputSchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!queryValidation.success) return validationErrorResponse(queryValidation.error) - logger.info(`[${requestId}] OPTIONS request received for /api/templates/approved/sanitized`) - - return new NextResponse(null, { - status: 200, - headers: { - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'X-API-Key, Content-Type', - }, - }) -}) diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index f6105233285..d48e5dffd80 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -1,18 +1,140 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' -import { imageProxyQuerySchema } from '@/lib/api/contracts/tools/media/image' -import { getValidationErrorMessage, searchParamsToObject } from '@/lib/api/server/validation' +import { + type ImageToolBody, + type imageProviders, + imageProxyQuerySchema, + imageToolContract, +} from '@/lib/api/contracts/tools/media/image' +import { + getValidationErrorMessage, + parseRequest, + searchParamsToObject, + validationErrorResponse, +} from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ImageProxyAPI') +export const dynamic = 'force-dynamic' +export const maxDuration = 600 + +type ImageProvider = (typeof imageProviders)[number] + +interface GeneratedImageResult { + buffer: Buffer + contentType: string + fileName: string + provider: ImageProvider + model: string + sourceUrl?: string + description?: string + revisedPrompt?: string + seed?: number + jobId?: string +} + +interface StoredImageResponse { + content: string + imageUrl: string + imageFile?: unknown + fileName: string + contentType: string + provider: ImageProvider + model: string + metadata: { + provider: ImageProvider + model: string + description?: string + revisedPrompt?: string + seed?: number + jobId?: string + contentType: string + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + logger.info(`[${requestId}] Image generation request started`) + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest( + imageToolContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid image generation request:`, error.issues) + return validationErrorResponse( + error, + getValidationErrorMessage(error, 'Invalid request data') + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + const provider = body.provider as ImageProvider + const { apiKey, model, prompt } = body + + if (prompt.length < 3 || prompt.length > 4000) { + return NextResponse.json( + { error: 'Prompt must be between 3 and 4000 characters' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Generating image with ${provider}, model: ${model || 'default'}`) + + let imageResult: GeneratedImageResult + try { + if (provider === 'openai') { + imageResult = await generateWithOpenAI(apiKey, body, requestId, logger) + } else if (provider === 'gemini') { + imageResult = await generateWithGemini(apiKey, body, requestId, logger) + } else if (provider === 'falai') { + imageResult = await generateWithFalAI(apiKey, body, requestId, logger) + } else { + return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 }) + } + } catch (error) { + logger.error(`[${requestId}] Image generation failed:`, error) + const errorMessage = getErrorMessage(error, 'Image generation failed') + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } + + const storedImage = await storeGeneratedImage(imageResult, body, authResult.userId, requestId) + + logger.info(`[${requestId}] Image generation completed successfully`, { + provider, + model: storedImage.model, + contentType: storedImage.contentType, + }) + + return NextResponse.json(storedImage) + } catch (error) { + logger.error(`[${requestId}] Image generation route error:`, error) + const errorMessage = getErrorMessage(error, 'Unknown error') + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) + /** * Proxy for fetching images * This allows client-side requests to fetch images from various sources while avoiding CORS issues @@ -99,14 +221,742 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } }) -export const OPTIONS = withRouteHandler(async () => { - return new NextResponse(null, { - status: 204, +const OPENAI_IMAGE_MODELS = [ + 'gpt-image-2', + 'gpt-image-1.5', + 'gpt-image-1', + 'gpt-image-1-mini', +] as const +const OPENAI_IMAGE_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const +const OPENAI_IMAGE_2_SIZES = [...OPENAI_IMAGE_SIZES, '2560x1440', '3840x2160'] as const +const OPENAI_IMAGE_QUALITIES = ['auto', 'low', 'medium', 'high'] as const +const OPENAI_IMAGE_BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const +const IMAGE_OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const +const OPENAI_MODERATION_LEVELS = ['auto', 'low'] as const + +const GEMINI_IMAGE_MODELS = [ + 'gemini-3.1-flash-image-preview', + 'gemini-3-pro-image-preview', + 'gemini-2.5-flash-image', +] as const +const GEMINI_BASE_ASPECT_RATIOS = [ + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '21:9', +] as const +const GEMINI_EXTREME_ASPECT_RATIOS = ['1:4', '1:8', '4:1', '8:1'] as const +const GEMINI_IMAGE_SIZES = ['512', '1K', '2K', '4K'] as const +const GEMINI_PRO_IMAGE_SIZES = ['1K', '2K', '4K'] as const + +interface FalAIImageModelConfig { + endpoint: string + defaultSize?: string + sizeOptions?: readonly string[] + defaultAspectRatio?: string + aspectRatios?: readonly string[] + defaultResolution?: string + resolutionOptions?: readonly string[] + defaultOutputFormat?: string + outputFormats?: readonly string[] + defaultQuality?: string + qualityOptions?: readonly string[] + defaultBackground?: string + backgroundOptions?: readonly string[] + defaultSafetyTolerance?: string + safetyToleranceOptions?: readonly string[] + maxNumImages?: number + supportsSeed?: boolean + supportsEnableSafetyChecker?: boolean + supportsEnableWebSearch?: boolean + supportsThinkingLevel?: boolean +} + +const FALAI_NANO_BANANA_ASPECT_RATIOS = [ + 'auto', + '21:9', + '16:9', + '3:2', + '4:3', + '5:4', + '1:1', + '4:5', + '3:4', + '2:3', + '9:16', +] as const +const FALAI_EXTREME_ASPECT_RATIOS = ['4:1', '1:4', '8:1', '1:8'] as const +const FALAI_STANDARD_IMAGE_SIZES = [ + 'square_hd', + 'square', + 'portrait_4_3', + 'portrait_16_9', + 'landscape_4_3', + 'landscape_16_9', +] as const +const FALAI_SEEDREAM_IMAGE_SIZES = [...FALAI_STANDARD_IMAGE_SIZES, 'auto_2K', 'auto_4K'] as const + +const FALAI_IMAGE_MODEL_CONFIGS: Record = { + 'nano-banana-2': { + endpoint: 'fal-ai/nano-banana-2', + defaultAspectRatio: 'auto', + aspectRatios: [...FALAI_NANO_BANANA_ASPECT_RATIOS, ...FALAI_EXTREME_ASPECT_RATIOS], + defaultResolution: '1K', + resolutionOptions: ['0.5K', '1K', '2K', '4K'], + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + defaultSafetyTolerance: '4', + safetyToleranceOptions: ['1', '2', '3', '4', '5', '6'], + maxNumImages: 4, + supportsSeed: true, + supportsEnableWebSearch: true, + supportsThinkingLevel: true, + }, + 'nano-banana-pro': { + endpoint: 'fal-ai/nano-banana-pro', + defaultAspectRatio: '1:1', + aspectRatios: FALAI_NANO_BANANA_ASPECT_RATIOS, + defaultResolution: '1K', + resolutionOptions: ['1K', '2K', '4K'], + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + defaultSafetyTolerance: '4', + safetyToleranceOptions: ['1', '2', '3', '4', '5', '6'], + maxNumImages: 4, + supportsSeed: true, + supportsEnableWebSearch: true, + }, + 'nano-banana': { + endpoint: 'fal-ai/nano-banana', + defaultAspectRatio: '1:1', + aspectRatios: FALAI_NANO_BANANA_ASPECT_RATIOS.filter((ratio) => ratio !== 'auto'), + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + defaultSafetyTolerance: '4', + safetyToleranceOptions: ['1', '2', '3', '4', '5', '6'], + maxNumImages: 4, + supportsSeed: true, + }, + 'gpt-image-1.5': { + endpoint: 'fal-ai/gpt-image-1.5', + defaultSize: '1024x1024', + sizeOptions: ['1024x1024', '1536x1024', '1024x1536'], + defaultQuality: 'high', + qualityOptions: ['low', 'medium', 'high'], + defaultBackground: 'auto', + backgroundOptions: OPENAI_IMAGE_BACKGROUNDS, + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + maxNumImages: 4, + }, + 'seedream-v4.5': { + endpoint: 'fal-ai/bytedance/seedream/v4.5/text-to-image', + defaultSize: 'auto_2K', + sizeOptions: FALAI_SEEDREAM_IMAGE_SIZES, + maxNumImages: 6, + supportsSeed: true, + supportsEnableSafetyChecker: true, + }, + 'flux-2-pro': { + endpoint: 'fal-ai/flux-2-pro', + defaultSize: 'landscape_4_3', + sizeOptions: FALAI_STANDARD_IMAGE_SIZES, + defaultOutputFormat: 'jpeg', + outputFormats: ['jpeg', 'png'], + defaultSafetyTolerance: '2', + safetyToleranceOptions: ['1', '2', '3', '4', '5'], + supportsSeed: true, + supportsEnableSafetyChecker: true, + }, + 'grok-imagine-image': { + endpoint: 'xai/grok-imagine-image', + defaultAspectRatio: '1:1', + aspectRatios: [ + '2:1', + '20:9', + '19.5:9', + '16:9', + '4:3', + '3:2', + '1:1', + '2:3', + '3:4', + '9:16', + '9:19.5', + '9:20', + '1:2', + ], + defaultResolution: '1k', + resolutionOptions: ['1k', '2k'], + defaultOutputFormat: 'jpeg', + outputFormats: IMAGE_OUTPUT_FORMATS, + maxNumImages: 4, + }, +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getStringProperty( + record: Record | undefined, + key: string +): string | undefined { + const value = record?.[key] + return typeof value === 'string' ? value : undefined +} + +function getNumberProperty( + record: Record | undefined, + key: string +): number | undefined { + const value = record?.[key] + return typeof value === 'number' ? value : undefined +} + +function firstRecord(value: unknown): Record | undefined { + return Array.isArray(value) ? value.find(isRecord) : undefined +} + +function pickAllowed( + value: string | undefined, + allowed: readonly string[], + fallback: string +): string { + return value && allowed.includes(value) ? value : fallback +} + +function clampInteger( + value: number | undefined, + min: number, + max: number, + fallback: number +): number { + if (typeof value !== 'number' || !Number.isInteger(value)) return fallback + return Math.min(Math.max(value, min), max) +} + +function getContentTypeForFormat(format: string | undefined): string { + if (format === 'jpeg') return 'image/jpeg' + if (format === 'webp') return 'image/webp' + return 'image/png' +} + +function extensionFromContentType(contentType: string): string { + if (contentType.includes('jpeg') || contentType.includes('jpg')) return 'jpg' + if (contentType.includes('webp')) return 'webp' + return 'png' +} + +async function bufferFromImageUrl(url: string): Promise<{ buffer: Buffer; contentType: string }> { + if (url.startsWith('data:')) { + const match = /^data:([^;]+);base64,(.+)$/u.exec(url) + if (!match) throw new Error('Invalid data URI image response') + return { + contentType: match[1], + buffer: Buffer.from(match[2], 'base64'), + } + } + + const urlValidation = await validateUrlWithDNS(url, 'imageUrl') + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + throw new Error(urlValidation.error || 'Generated image URL failed validation') + } + + const imageResponse = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { + method: 'GET', + }) + if (!imageResponse.ok) { + await imageResponse.text().catch(() => {}) + throw new Error(`Failed to download generated image: ${imageResponse.status}`) + } + + const contentType = imageResponse.headers.get('content-type') || 'image/png' + const arrayBuffer = await imageResponse.arrayBuffer() + return { buffer: Buffer.from(arrayBuffer), contentType } +} + +async function generateWithOpenAI( + apiKey: string, + body: ImageToolBody, + requestId: string, + logger: ReturnType +): Promise { + const model = pickAllowed(body.model, OPENAI_IMAGE_MODELS, 'gpt-image-1.5') + const size = + model === 'gpt-image-2' + ? pickAllowed(body.size, OPENAI_IMAGE_2_SIZES, 'auto') + : pickAllowed(body.size, OPENAI_IMAGE_SIZES, 'auto') + const outputFormat = pickAllowed(body.outputFormat, IMAGE_OUTPUT_FORMATS, 'png') + const requestBody: Record = { + model, + prompt: body.prompt, + size, + n: 1, + } + + if (body.quality) { + requestBody.quality = pickAllowed(body.quality, OPENAI_IMAGE_QUALITIES, 'auto') + } + if (body.background) { + requestBody.background = pickAllowed(body.background, OPENAI_IMAGE_BACKGROUNDS, 'auto') + } + if (body.outputFormat) { + requestBody.output_format = outputFormat + } + if (body.moderation) { + requestBody.moderation = pickAllowed(body.moderation, OPENAI_MODERATION_LEVELS, 'auto') + } + + const openaiResponse = await fetch('https://api.openai.com/v1/images/generations', { + method: 'POST', headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Max-Age': '86400', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify(requestBody), }) -}) + + if (!openaiResponse.ok) { + const error = await openaiResponse.text() + throw new Error(`OpenAI API error: ${openaiResponse.status} - ${error}`) + } + + const data = (await openaiResponse.json()) as unknown + if (!isRecord(data)) { + throw new Error('Invalid OpenAI image response') + } + + const firstImage = firstRecord(data.data) + const base64Image = getStringProperty(firstImage, 'b64_json') + const imageUrl = getStringProperty(firstImage, 'url') + const revisedPrompt = getStringProperty(firstImage, 'revised_prompt') + let buffer: Buffer + let contentType = getContentTypeForFormat(outputFormat) + + if (base64Image) { + buffer = Buffer.from(base64Image, 'base64') + } else if (imageUrl) { + const downloaded = await bufferFromImageUrl(imageUrl) + buffer = downloaded.buffer + contentType = downloaded.contentType + } else { + logger.error(`[${requestId}] OpenAI response missing image payload`) + throw new Error('No image data found in OpenAI response') + } + + return { + buffer, + contentType, + fileName: `openai-${model}.${extensionFromContentType(contentType)}`, + provider: 'openai', + model, + sourceUrl: imageUrl, + revisedPrompt, + } +} + +async function generateWithGemini( + apiKey: string, + body: ImageToolBody, + requestId: string, + logger: ReturnType +): Promise { + const model = pickAllowed(body.model, GEMINI_IMAGE_MODELS, 'gemini-3.1-flash-image-preview') + const aspectRatios = + model === 'gemini-3.1-flash-image-preview' + ? [...GEMINI_BASE_ASPECT_RATIOS, ...GEMINI_EXTREME_ASPECT_RATIOS] + : GEMINI_BASE_ASPECT_RATIOS + const imageConfig: Record = {} + + if (body.aspectRatio) { + imageConfig.aspectRatio = pickAllowed(body.aspectRatio, aspectRatios, '1:1') + } + + if (model === 'gemini-3.1-flash-image-preview' && body.resolution) { + imageConfig.imageSize = pickAllowed(body.resolution, GEMINI_IMAGE_SIZES, '1K') + } else if (model === 'gemini-3-pro-image-preview' && body.resolution) { + imageConfig.imageSize = pickAllowed(body.resolution, GEMINI_PRO_IMAGE_SIZES, '1K') + } + + const requestBody: Record = { + contents: [ + { + parts: [{ text: body.prompt }], + }, + ], + } + + requestBody.generationConfig = { + responseModalities: ['TEXT', 'IMAGE'], + ...(Object.keys(imageConfig).length > 0 && { imageConfig }), + } + + const geminiResponse = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, + { + method: 'POST', + headers: { + 'x-goog-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + } + ) + + if (!geminiResponse.ok) { + const error = await geminiResponse.text() + throw new Error(`Gemini API error: ${geminiResponse.status} - ${error}`) + } + + const data = (await geminiResponse.json()) as unknown + if (!isRecord(data)) { + throw new Error('Invalid Gemini image response') + } + + const candidate = firstRecord(data.candidates) + const content = isRecord(candidate?.content) ? candidate.content : undefined + const parts = Array.isArray(content?.parts) ? content.parts : [] + const textPart = parts.find((part) => isRecord(part) && typeof part.text === 'string') + const imagePart = parts.find((part) => { + if (!isRecord(part)) return false + return isRecord(part.inlineData) || isRecord(part.inline_data) + }) + + if (!isRecord(imagePart)) { + logger.error(`[${requestId}] Gemini response missing image part`) + throw new Error('No image data found in Gemini response') + } + + const inlineData = isRecord(imagePart.inlineData) + ? imagePart.inlineData + : isRecord(imagePart.inline_data) + ? imagePart.inline_data + : undefined + const base64Image = getStringProperty(inlineData, 'data') + const contentType = + getStringProperty(inlineData, 'mimeType') || + getStringProperty(inlineData, 'mime_type') || + 'image/png' + + if (!base64Image) { + throw new Error('Gemini image response missing inline image data') + } + + return { + buffer: Buffer.from(base64Image, 'base64'), + contentType, + fileName: `gemini-${model}.${extensionFromContentType(contentType)}`, + provider: 'gemini', + model, + description: isRecord(textPart) ? getStringProperty(textPart, 'text') : undefined, + } +} + +function buildFalAIQueueUrl(endpoint: string, requestId: string, path: 'status' | 'response') { + return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}` +} + +function getFalAIErrorMessage(error: unknown): string { + if (typeof error === 'string') return error + if (isRecord(error)) { + return ( + getStringProperty(error, 'message') || + getStringProperty(error, 'detail') || + JSON.stringify(error) + ) + } + return 'Unknown Fal.ai error' +} + +async function generateWithFalAI( + apiKey: string, + body: ImageToolBody, + requestId: string, + logger: ReturnType +): Promise { + const model = body.model || 'nano-banana-2' + const modelConfig = FALAI_IMAGE_MODEL_CONFIGS[model] + if (!modelConfig) { + throw new Error(`Unknown Fal.ai image model: ${model}`) + } + + const requestBody: Record = { + prompt: body.prompt, + sync_mode: false, + } + + if (modelConfig.maxNumImages) { + requestBody.num_images = clampInteger(body.numImages, 1, modelConfig.maxNumImages, 1) + } + if (modelConfig.supportsSeed && body.seed !== undefined) { + requestBody.seed = body.seed + } + if (modelConfig.sizeOptions && modelConfig.defaultSize) { + requestBody.image_size = pickAllowed( + body.size, + modelConfig.sizeOptions, + modelConfig.defaultSize + ) + } + if (modelConfig.aspectRatios && modelConfig.defaultAspectRatio) { + requestBody.aspect_ratio = pickAllowed( + body.aspectRatio, + modelConfig.aspectRatios, + modelConfig.defaultAspectRatio + ) + } + if (modelConfig.resolutionOptions && modelConfig.defaultResolution) { + requestBody.resolution = pickAllowed( + body.resolution, + modelConfig.resolutionOptions, + modelConfig.defaultResolution + ) + } + if (modelConfig.outputFormats && modelConfig.defaultOutputFormat) { + requestBody.output_format = pickAllowed( + body.outputFormat, + modelConfig.outputFormats, + modelConfig.defaultOutputFormat + ) + } + if (modelConfig.qualityOptions && modelConfig.defaultQuality) { + requestBody.quality = pickAllowed( + body.quality, + modelConfig.qualityOptions, + modelConfig.defaultQuality + ) + } + if (modelConfig.backgroundOptions && modelConfig.defaultBackground) { + requestBody.background = pickAllowed( + body.background, + modelConfig.backgroundOptions, + modelConfig.defaultBackground + ) + } + if (modelConfig.safetyToleranceOptions && modelConfig.defaultSafetyTolerance) { + requestBody.safety_tolerance = pickAllowed( + body.safetyTolerance, + modelConfig.safetyToleranceOptions, + modelConfig.defaultSafetyTolerance + ) + } + if (modelConfig.supportsEnableSafetyChecker && body.enableSafetyChecker !== undefined) { + requestBody.enable_safety_checker = body.enableSafetyChecker + } + if (modelConfig.supportsEnableWebSearch && body.enableWebSearch !== undefined) { + requestBody.enable_web_search = body.enableWebSearch + } + if (modelConfig.supportsThinkingLevel && body.thinkingLevel) { + requestBody.thinking_level = pickAllowed(body.thinkingLevel, ['minimal', 'high'], 'minimal') + } + + const createResponse = await fetch(`https://queue.fal.run/${modelConfig.endpoint}`, { + method: 'POST', + headers: { + Authorization: `Key ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!createResponse.ok) { + const error = await createResponse.text() + throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`) + } + + const createData = (await createResponse.json()) as unknown + if (!isRecord(createData)) { + throw new Error('Invalid Fal.ai queue response') + } + + const falRequestId = getStringProperty(createData, 'request_id') + if (!falRequestId) { + throw new Error('Fal.ai queue response missing request_id') + } + + const statusUrl = + getStringProperty(createData, 'status_url') || + buildFalAIQueueUrl(modelConfig.endpoint, falRequestId, 'status') + const responseUrl = + getStringProperty(createData, 'response_url') || + buildFalAIQueueUrl(modelConfig.endpoint, falRequestId, 'response') + + logger.info(`[${requestId}] Fal.ai image request created: ${falRequestId}`) + + const pollIntervalMs = 3000 + const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs) + let attempts = 0 + + while (attempts < maxAttempts) { + await sleep(pollIntervalMs) + + const statusResponse = await fetch(statusUrl, { + headers: { + Authorization: `Key ${apiKey}`, + }, + }) + + if (!statusResponse.ok) { + await statusResponse.text().catch(() => {}) + throw new Error(`Fal.ai status check failed: ${statusResponse.status}`) + } + + const statusData = (await statusResponse.json()) as unknown + if (!isRecord(statusData)) { + throw new Error('Invalid Fal.ai status response') + } + + const status = getStringProperty(statusData, 'status') + if (status === 'COMPLETED') { + const statusError = statusData.error + if (statusError) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) + } + + const resultResponse = await fetch( + getStringProperty(statusData, 'response_url') || responseUrl, + { + headers: { + Authorization: `Key ${apiKey}`, + }, + } + ) + + if (!resultResponse.ok) { + await resultResponse.text().catch(() => {}) + throw new Error(`Failed to fetch Fal.ai result: ${resultResponse.status}`) + } + + const resultData = (await resultResponse.json()) as unknown + if (!isRecord(resultData)) { + throw new Error('Invalid Fal.ai result response') + } + + const firstImage = firstRecord(resultData.images) + const imageUrl = + getStringProperty(firstImage, 'url') || + getStringProperty(firstImage, 'data') || + getStringProperty(firstImage, 'content') + if (!imageUrl) { + throw new Error('No image URL in Fal.ai response') + } + + const downloaded = await bufferFromImageUrl(imageUrl) + const contentType = + getStringProperty(firstImage, 'content_type') || + getStringProperty(firstImage, 'contentType') || + downloaded.contentType + const fileName = + getStringProperty(firstImage, 'file_name') || + getStringProperty(firstImage, 'fileName') || + `falai-${model}.${extensionFromContentType(contentType)}` + + return { + buffer: downloaded.buffer, + contentType, + fileName, + provider: 'falai', + model, + sourceUrl: imageUrl.startsWith('data:') ? undefined : imageUrl, + description: getStringProperty(resultData, 'description'), + revisedPrompt: getStringProperty(resultData, 'revised_prompt'), + seed: getNumberProperty(resultData, 'seed'), + jobId: falRequestId, + } + } + + if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`) + } + + attempts += 1 + } + + throw new Error('Fal.ai image generation timed out') +} + +async function storeGeneratedImage( + imageResult: GeneratedImageResult, + body: ImageToolBody, + userId: string, + requestId: string +): Promise { + const timestamp = Date.now() + const safeFileName = imageResult.fileName || `image-${imageResult.provider}-${timestamp}.png` + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : null + + if (executionContext) { + const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') + const imageFile = await uploadExecutionFile( + executionContext, + imageResult.buffer, + safeFileName, + imageResult.contentType, + userId + ) + + return { + content: imageFile.url, + imageUrl: imageFile.url, + imageFile, + fileName: safeFileName, + contentType: imageResult.contentType, + provider: imageResult.provider, + model: imageResult.model, + metadata: { + provider: imageResult.provider, + model: imageResult.model, + description: imageResult.description, + revisedPrompt: imageResult.revisedPrompt, + seed: imageResult.seed, + jobId: imageResult.jobId, + contentType: imageResult.contentType, + }, + } + } + + const { StorageService } = await import('@/lib/uploads') + const fileInfo = await StorageService.uploadFile({ + file: imageResult.buffer, + fileName: safeFileName, + contentType: imageResult.contentType, + context: 'copilot', + }) + const imageUrl = `${getBaseUrl()}${fileInfo.path}` + logger.info(`[${requestId}] Stored generated image fallback`, { + fileName: safeFileName, + size: imageResult.buffer.length, + }) + + return { + content: imageUrl, + imageUrl, + fileName: safeFileName, + contentType: imageResult.contentType, + provider: imageResult.provider, + model: imageResult.model, + metadata: { + provider: imageResult.provider, + model: imageResult.model, + description: imageResult.description, + revisedPrompt: imageResult.revisedPrompt, + seed: imageResult.seed, + jobId: imageResult.jobId, + contentType: imageResult.contentType, + }, + } +} diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 84d930d9c7b..693a6e192c2 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -84,13 +84,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - // Validate aspect ratio (Veo only supports 16:9 and 9:16) - const validAspectRatios = provider === 'veo' ? ['16:9', '9:16'] : ['16:9', '9:16', '1:1'] - if (aspectRatio && !validAspectRatios.includes(aspectRatio)) { - return NextResponse.json( - { error: `Aspect ratio must be ${validAspectRatios.join(', ')}` }, - { status: 400 } - ) + if (provider !== 'falai') { + const validAspectRatios = provider === 'veo' ? ['16:9', '9:16'] : ['16:9', '9:16', '1:1'] + if (aspectRatio && !validAspectRatios.includes(aspectRatio)) { + return NextResponse.json( + { error: `Aspect ratio must be ${validAspectRatios.join(', ')}` }, + { status: 400 } + ) + } } logger.info(`[${requestId}] Generating video with ${provider}, model: ${model || 'default'}`) @@ -166,10 +167,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (provider === 'minimax') { const result = await generateWithMiniMax( apiKey, - model || 'hailuo-02', + model || 'hailuo-2.3', prompt, duration || 6, - body.promptOptimizer !== false, // Default true + body.promptOptimizer !== false, + body.endpoint, requestId, logger ) @@ -185,6 +187,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } + const validationError = getFalAIValidationError(model, duration, aspectRatio, resolution) + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }) + } const result = await generateWithFalAI( apiKey, model, @@ -193,6 +199,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { aspectRatio, resolution, body.promptOptimizer, + body.generateAudio, requestId, logger ) @@ -635,27 +642,25 @@ async function generateWithMiniMax( prompt: string, duration: number, promptOptimizer: boolean, + endpoint: string | undefined, requestId: string, logger: ReturnType ): Promise<{ buffer: Buffer; width: number; height: number; jobId: string; duration: number }> { logger.info(`[${requestId}] Starting MiniMax Hailuo generation via MiniMax Platform API`) logger.info( - `[${requestId}] Request params - model: ${model}, duration: ${duration}, promptOptimizer: ${promptOptimizer}` + `[${requestId}] Request params - model: ${model}, duration: ${duration}, endpoint: ${endpoint || 'standard'}, promptOptimizer: ${promptOptimizer}` ) - // Determine resolution and dimensions based on duration - // MiniMax-Hailuo-02 supports 768P (6s) or 1080P (10s) - const resolution = duration === 10 ? '1080P' : '768P' - const dimensions = duration === 10 ? { width: 1920, height: 1080 } : { width: 1360, height: 768 } + const useProResolution = endpoint === 'pro' && duration === 6 + const resolution = useProResolution ? '1080P' : '768P' + const dimensions = useProResolution ? { width: 1920, height: 1080 } : { width: 1360, height: 768 } logger.info( `[${requestId}] Using resolution: ${resolution}, dimensions: ${dimensions.width}x${dimensions.height}` ) - // Map our model ID to MiniMax model name const minimaxModel = model === 'hailuo-02' ? 'MiniMax-Hailuo-02' : 'MiniMax-Hailuo-2.3' - // Create video generation request via MiniMax Platform API const createResponse = await fetch('https://api.minimax.io/v1/video_generation', { method: 'POST', headers: { @@ -782,32 +787,290 @@ async function generateWithMiniMax( throw new Error('MiniMax generation timed out') } -// Helper function to strip subpaths from Fal.ai model IDs for status/result endpoints -function getBaseModelId(fullModelId: string): string { - const parts = fullModelId.split('/') - // Keep only the first two parts (e.g., "fal-ai/sora-2" from "fal-ai/sora-2/text-to-video") - if (parts.length > 2) { - return parts.slice(0, 2).join('/') - } - return fullModelId +type FalAIDurationFormat = 'number' | 'seconds' | 'string' + +interface FalAIModelConfig { + endpoint: string + durationFormat?: FalAIDurationFormat + durationOptions?: readonly number[] + supportsAspectRatio?: boolean + aspectRatioOptions?: readonly string[] + supportsResolution?: boolean + resolutionOptions?: readonly string[] + supportsPromptOptimizer?: boolean + supportsGenerateAudio?: boolean +} + +interface FalAIRequestBody { + prompt: string + duration?: number | string + aspect_ratio?: string + resolution?: string + prompt_optimizer?: boolean + generate_audio?: boolean +} + +const FALAI_MODEL_CONFIGS: Record = { + 'veo-3.1': { + endpoint: 'fal-ai/veo3.1', + durationFormat: 'seconds', + durationOptions: [4, 6, 8], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p', '1080p', '4k'], + supportsGenerateAudio: true, + }, + 'veo-3.1-fast': { + endpoint: 'fal-ai/veo3.1/fast', + durationFormat: 'seconds', + durationOptions: [4, 6, 8], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p', '1080p', '4k'], + supportsGenerateAudio: true, + }, + 'sora-2': { + endpoint: 'fal-ai/sora-2/text-to-video', + durationFormat: 'number', + durationOptions: [4, 8, 12, 16, 20], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p'], + }, + 'sora-2-pro': { + endpoint: 'fal-ai/sora-2/text-to-video/pro', + durationFormat: 'number', + durationOptions: [4, 8, 12, 16, 20], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p', '1080p', 'true_1080p'], + }, + 'seedance-2.0': { + endpoint: 'bytedance/seedance-2.0/text-to-video', + durationFormat: 'string', + durationOptions: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['auto', '21:9', '16:9', '4:3', '1:1', '3:4', '9:16'], + supportsResolution: true, + resolutionOptions: ['480p', '720p', '1080p'], + supportsGenerateAudio: true, + }, + 'seedance-2.0-fast': { + endpoint: 'bytedance/seedance-2.0/fast/text-to-video', + durationFormat: 'string', + durationOptions: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['auto', '21:9', '16:9', '4:3', '1:1', '3:4', '9:16'], + supportsResolution: true, + resolutionOptions: ['480p', '720p'], + supportsGenerateAudio: true, + }, + 'kling-v3-pro': { + endpoint: 'fal-ai/kling-video/v3/pro/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-v3-4k': { + endpoint: 'fal-ai/kling-video/v3/4k/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-o3-pro': { + endpoint: 'fal-ai/kling-video/o3/pro/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-o3-4k': { + endpoint: 'fal-ai/kling-video/o3/4k/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-2.5-turbo-pro': { + endpoint: 'fal-ai/kling-video/v2.5-turbo/pro/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + }, + 'kling-2.1-pro': { + endpoint: 'fal-ai/kling-video/v2.1/master/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + }, + 'minimax-hailuo-2.3-pro': { + endpoint: 'fal-ai/minimax/hailuo-2.3/pro/text-to-video', + supportsPromptOptimizer: true, + }, + 'minimax-hailuo-2.3-standard': { + endpoint: 'fal-ai/minimax/hailuo-2.3/standard/text-to-video', + durationFormat: 'string', + durationOptions: [6, 10], + supportsPromptOptimizer: true, + }, + 'minimax-hailuo-02-pro': { + endpoint: 'fal-ai/minimax/hailuo-02/pro/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + supportsPromptOptimizer: true, + }, + 'minimax-hailuo-02-standard': { + endpoint: 'fal-ai/minimax/hailuo-02/standard/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + supportsPromptOptimizer: true, + }, + 'wan-2.2-a14b-turbo': { + endpoint: 'fal-ai/wan/v2.2-a14b/text-to-video/turbo', + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsResolution: true, + resolutionOptions: ['480p', '580p', '720p'], + }, + 'wan-2.1': { + endpoint: 'fal-ai/wan-t2v', + }, + 'ltx-2.3': { + endpoint: 'fal-ai/ltx-2.3/text-to-video', + durationFormat: 'number', + durationOptions: [6, 8, 10], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['1080p', '1440p', '2160p'], + supportsGenerateAudio: true, + }, + 'ltx-2.3-fast': { + endpoint: 'fal-ai/ltx-2.3/text-to-video/fast', + durationFormat: 'number', + durationOptions: [6, 8, 10, 12, 14, 16, 18, 20], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['1080p', '1440p', '2160p'], + supportsGenerateAudio: true, + }, + 'ltxv-0.9.8': { + endpoint: 'fal-ai/ltxv-13b-098-distilled', + }, +} + +function formatFalAIDuration( + format: FalAIDurationFormat | undefined, + duration: number | undefined +): string | number | undefined { + if (!format || duration === undefined) return undefined + + if (format === 'number') return duration + if (format === 'seconds') return `${duration}s` + return String(duration) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getStringProperty( + record: Record | undefined, + key: string +): string | undefined { + const value = record?.[key] + return typeof value === 'string' ? value : undefined } -// Helper function to format duration based on model requirements -function formatDuration(model: string, duration: number | undefined): string | number | undefined { - if (duration === undefined) return undefined +function getNumberProperty( + record: Record | undefined, + key: string +): number | undefined { + const value = record?.[key] + return typeof value === 'number' ? value : undefined +} - // Veo 3.1 requires duration with "s" suffix (e.g., "8s") - if (model === 'veo-3.1') { - return `${duration}s` +function formatAllowedValues(allowed: readonly (number | string)[]): string { + return allowed.map(String).join(', ') +} + +function getFalAIValidationError( + model: string, + duration: number | undefined, + aspectRatio: string | undefined, + resolution: string | undefined +): string | undefined { + const modelConfig = FALAI_MODEL_CONFIGS[model] + if (!modelConfig) { + return `Unknown Fal.ai model: ${model}` } - // Sora 2 requires numeric duration - if (model === 'sora-2') { - return duration + if ( + duration !== undefined && + modelConfig.durationOptions && + !modelConfig.durationOptions.includes(duration) + ) { + return `Invalid duration for Fal.ai model ${model}. Supported durations: ${formatAllowedValues(modelConfig.durationOptions)}` } - // Other models use string format - return String(duration) + if (aspectRatio) { + if (!modelConfig.supportsAspectRatio) { + return `Fal.ai model ${model} does not support aspect ratio` + } + + if (modelConfig.aspectRatioOptions && !modelConfig.aspectRatioOptions.includes(aspectRatio)) { + return `Invalid aspect ratio for Fal.ai model ${model}. Supported aspect ratios: ${formatAllowedValues(modelConfig.aspectRatioOptions)}` + } + } + + if (resolution) { + if (!modelConfig.supportsResolution) { + return `Fal.ai model ${model} does not support resolution` + } + + if (modelConfig.resolutionOptions && !modelConfig.resolutionOptions.includes(resolution)) { + return `Invalid resolution for Fal.ai model ${model}. Supported resolutions: ${formatAllowedValues(modelConfig.resolutionOptions)}` + } + } + + if ( + model === 'ltx-2.3-fast' && + duration !== undefined && + duration > 10 && + resolution && + resolution !== '1080p' + ) { + return 'Fal.ai model ltx-2.3-fast only supports durations over 10 seconds with 1080p resolution' + } + + return undefined +} + +function getFalAIErrorMessage(error: unknown): string { + if (typeof error === 'string') return error + if (isRecord(error)) return getStringProperty(error, 'message') || JSON.stringify(error) + return 'Unknown error' +} + +function buildFalAIQueueUrl( + endpoint: string, + requestId: string, + path: 'response' | 'status' +): string { + return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}` } async function generateWithFalAI( @@ -818,64 +1081,41 @@ async function generateWithFalAI( aspectRatio: string | undefined, resolution: string | undefined, promptOptimizer: boolean | undefined, + generateAudio: boolean | undefined, requestId: string, logger: ReturnType ): Promise<{ buffer: Buffer; width: number; height: number; jobId: string; duration: number }> { logger.info(`[${requestId}] Starting Fal.ai generation with model: ${model}`) - // Map our model IDs to Fal.ai model paths - const modelMap: { [key: string]: string } = { - 'veo-3.1': 'fal-ai/veo3.1', - 'sora-2': 'fal-ai/sora-2/text-to-video', - 'kling-2.5-turbo-pro': 'fal-ai/kling-video/v2.5-turbo/pro/text-to-video', - 'kling-2.1-pro': 'fal-ai/kling-video/v2.1/master/text-to-video', - 'minimax-hailuo-2.3-pro': 'fal-ai/minimax/hailuo-02/pro/text-to-video', - 'minimax-hailuo-2.3-standard': 'fal-ai/minimax/hailuo-02/standard/text-to-video', - 'wan-2.1': 'fal-ai/wan-t2v', - 'ltxv-0.9.8': 'fal-ai/ltxv-13b-098-distilled', - } - - const falModelId = modelMap[model] - if (!falModelId) { + const modelConfig = FALAI_MODEL_CONFIGS[model] + if (!modelConfig) { throw new Error(`Unknown Fal.ai model: ${model}`) } - // Build request body based on model requirements - const requestBody: any = { prompt } - - // Models that support duration and aspect_ratio parameters - const supportsStandardParams = [ - 'kling-2.5-turbo-pro', - 'kling-2.1-pro', - 'minimax-hailuo-2.3-pro', - 'minimax-hailuo-2.3-standard', - ] - - // Models that only need prompt (minimal params) - const minimalParamModels = ['ltxv-0.9.8', 'wan-2.1', 'veo-3.1', 'sora-2'] + const requestBody: FalAIRequestBody = { prompt } + const formattedDuration = formatFalAIDuration(modelConfig.durationFormat, duration) - if (supportsStandardParams.includes(model)) { - // Kling and MiniMax models support duration and aspect_ratio - const formattedDuration = formatDuration(model, duration) - if (formattedDuration !== undefined) { - requestBody.duration = formattedDuration - } + if (formattedDuration !== undefined) { + requestBody.duration = formattedDuration + } - if (aspectRatio) { - requestBody.aspect_ratio = aspectRatio - } + if (modelConfig.supportsAspectRatio && aspectRatio) { + requestBody.aspect_ratio = aspectRatio + } - if (resolution) { - requestBody.resolution = resolution - } + if (modelConfig.supportsResolution && resolution) { + requestBody.resolution = resolution } - // MiniMax models support prompt optimizer - if (model.startsWith('minimax-hailuo') && promptOptimizer !== undefined) { + if (modelConfig.supportsPromptOptimizer && promptOptimizer !== undefined) { requestBody.prompt_optimizer = promptOptimizer } - const createResponse = await fetch(`https://queue.fal.run/${falModelId}`, { + if (modelConfig.supportsGenerateAudio && generateAudio !== undefined) { + requestBody.generate_audio = generateAudio + } + + const createResponse = await fetch(`https://queue.fal.run/${modelConfig.endpoint}`, { method: 'POST', headers: { Authorization: `Key ${apiKey}`, @@ -889,13 +1129,24 @@ async function generateWithFalAI( throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() - const requestIdFal = createData.request_id + const createData = (await createResponse.json()) as unknown + if (!isRecord(createData)) { + throw new Error('Invalid Fal.ai queue response') + } - logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`) + const requestIdFal = getStringProperty(createData, 'request_id') + if (!requestIdFal) { + throw new Error('Fal.ai queue response missing request_id') + } + + const statusUrl = + getStringProperty(createData, 'status_url') || + buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'status') + const responseUrl = + getStringProperty(createData, 'response_url') || + buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'response') - // Get base model ID (without subpath) for status and result endpoints - const baseModelId = getBaseModelId(falModelId) + logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`) const pollIntervalMs = 5000 const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs) @@ -904,27 +1155,32 @@ async function generateWithFalAI( while (attempts < maxAttempts) { await sleep(pollIntervalMs) - const statusResponse = await fetch( - `https://queue.fal.run/${baseModelId}/requests/${requestIdFal}/status`, - { - headers: { - Authorization: `Key ${apiKey}`, - }, - } - ) + const statusResponse = await fetch(statusUrl, { + headers: { + Authorization: `Key ${apiKey}`, + }, + }) if (!statusResponse.ok) { await statusResponse.text().catch(() => {}) throw new Error(`Fal.ai status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = (await statusResponse.json()) as unknown + if (!isRecord(statusData)) { + throw new Error('Invalid Fal.ai status response') + } + + if (getStringProperty(statusData, 'status') === 'COMPLETED') { + const statusError = statusData.error + if (statusError) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) + } - if (statusData.status === 'COMPLETED') { logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`) const resultResponse = await fetch( - `https://queue.fal.run/${baseModelId}/requests/${requestIdFal}`, + getStringProperty(statusData, 'response_url') || responseUrl, { headers: { Authorization: `Key ${apiKey}`, @@ -937,9 +1193,15 @@ async function generateWithFalAI( throw new Error(`Failed to fetch result: ${resultResponse.status}`) } - const resultData = await resultResponse.json() + const resultData = (await resultResponse.json()) as unknown + if (!isRecord(resultData)) { + throw new Error('Invalid Fal.ai result response') + } - const videoUrl = resultData.video?.url || resultData.output?.url + const videoOutput = isRecord(resultData.video) ? resultData.video : undefined + const fallbackOutput = isRecord(resultData.output) ? resultData.output : undefined + const videoUrl = + getStringProperty(videoOutput, 'url') || getStringProperty(fallbackOutput, 'url') if (!videoUrl) { throw new Error('No video URL in response') } @@ -952,11 +1214,10 @@ async function generateWithFalAI( const arrayBuffer = await videoResponse.arrayBuffer() - // Try to get dimensions from response, or calculate from aspect ratio - let width = resultData.video?.width || 1920 - let height = resultData.video?.height || 1080 + let width = getNumberProperty(videoOutput, 'width') || 1920 + let height = getNumberProperty(videoOutput, 'height') || 1080 - if (!resultData.video?.width && aspectRatio) { + if (!getNumberProperty(videoOutput, 'width') && aspectRatio?.includes(':')) { const dims = getVideoDimensions(aspectRatio, resolution || '1080p') width = dims.width height = dims.height @@ -967,12 +1228,12 @@ async function generateWithFalAI( width, height, jobId: requestIdFal, - duration: duration || 5, + duration: getNumberProperty(videoOutput, 'duration') || duration || 5, } } - if (statusData.status === 'FAILED') { - throw new Error(`Fal.ai generation failed: ${statusData.error || 'Unknown error'}`) + if (['ERROR', 'FAILED', 'CANCELLED'].includes(getStringProperty(statusData, 'status') || '')) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`) } attempts++ @@ -986,13 +1247,20 @@ function getVideoDimensions( resolution: string ): { width: number; height: number } { let height: number - if (resolution === '4k') { + if (resolution === '4k' || resolution === '2160p') { height = 2160 + } else if (resolution === 'true_1080p') { + height = 1080 } else { - height = Number.parseInt(resolution.replace('p', '')) + const parsedHeight = Number.parseInt(resolution.replace('p', '')) + height = Number.isFinite(parsedHeight) ? parsedHeight : 1080 } const [ratioW, ratioH] = aspectRatio.split(':').map(Number) + if (!Number.isFinite(ratioW) || !Number.isFinite(ratioH) || ratioH === 0) { + return { width: Math.round((height * 16) / 9), height } + } + const width = Math.round((height * ratioW) / ratioH) return { width, height } diff --git a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts index 16808cca6f9..38e476f6522 100644 --- a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts @@ -5,11 +5,36 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthType } from '@/lib/auth/hybrid' +import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' +import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest' +import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' +import { storeLargeValue } from '@/lib/execution/payloads/store' +import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors' import type { ExecutionResult } from '@/lib/workflows/types' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' +const { mockDownloadFile, mockUploadFile, uploadedFiles } = vi.hoisted(() => ({ + mockDownloadFile: vi.fn(), + mockUploadFile: vi.fn(), + uploadedFiles: new Map(), +})) + +const MATERIALIZATION_CONTEXT = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + userId: 'user-1', +} + +vi.mock('@/lib/uploads', () => ({ + StorageService: { + downloadFile: mockDownloadFile, + uploadFile: mockUploadFile, + }, +})) + function buildExecutionResult(overrides: Partial = {}): ExecutionResult { return { success: true, @@ -38,6 +63,16 @@ describe('Response block gating by auth type', () => { let resultWithResponseBlock: ExecutionResult beforeEach(() => { + vi.clearAllMocks() + clearLargeValueCacheForTests() + uploadedFiles.clear() + mockUploadFile.mockImplementation(async ({ customKey, file }) => { + uploadedFiles.set(customKey, file) + return { key: customKey } + }) + mockDownloadFile.mockImplementation( + async ({ key }) => uploadedFiles.get(key) ?? Buffer.from('{}') + ) resultWithResponseBlock = buildExecutionResult() }) @@ -75,14 +110,14 @@ describe('Response block gating by auth type', () => { expect(shouldFormatAsResponseBlock).toBe(false) }) - it('should apply Response block formatting for API key callers', () => { + it('should apply Response block formatting for API key callers', async () => { const authType = AuthType.API_KEY const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock) const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock expect(shouldFormatAsResponseBlock).toBe(true) - const response = createHttpResponseFromBlock(resultWithResponseBlock) + const response = await createHttpResponseFromBlock(resultWithResponseBlock) expect(response.status).toBe(200) }) @@ -95,7 +130,7 @@ describe('Response block gating by auth type', () => { }) it('should return raw user data via createHttpResponseFromBlock', async () => { - const response = createHttpResponseFromBlock(resultWithResponseBlock) + const response = await createHttpResponseFromBlock(resultWithResponseBlock) const body = await response.json() // Response block returns the user-defined data directly (no success/executionId wrapper) @@ -104,12 +139,293 @@ describe('Response block gating by auth type', () => { expect(body.executionId).toBeUndefined() }) - it('should respect custom status codes from Response block', () => { + it('should respect custom status codes from Response block', async () => { const result = buildExecutionResult({ output: { data: { error: 'Not found' }, status: 404, headers: {} }, }) - const response = createHttpResponseFromBlock(result) + const response = await createHttpResponseFromBlock(result) expect(response.status).toBe(404) }) + + it('should materialize manifest data for Response block HTTP output', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + key: `SIM-${index}`, + payload: 'x'.repeat(100), + })) + const output = await compactExecutionPayload( + { + data: { rows }, + status: 200, + headers: {}, + }, + { + ...MATERIALIZATION_CONTEXT, + requireDurable: true, + preserveRoot: true, + thresholdBytes: 1024, + } + ) + const response = await createHttpResponseFromBlock( + buildExecutionResult({ output }), + MATERIALIZATION_CONTEXT + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.rows).toEqual(rows) + expect(body.success).toBeUndefined() + }) + + it('should materialize Response block manifests from an allowed source execution', async () => { + const rows = [{ key: 'SIM-1' }, { key: 'SIM-2' }] + const manifest = await createLargeArrayManifest(rows, { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + }) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + const body = await response.json() + + expect(body.rows).toEqual(rows) + }) + + it('should reject Response block manifests from non-source same-workflow executions', async () => { + const manifest = await createLargeArrayManifest([{ key: 'SIM-stale' }], { + ...MATERIALIZATION_CONTEXT, + executionId: 'stale-execution-1', + }) + + await expect( + createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + ).rejects.toThrow('Large execution value is not available in this execution') + }) + + it('should materialize Response block manifests inherited by the source snapshot', async () => { + const rows = [{ key: 'SIM-inherited' }] + const manifest = await createLargeArrayManifest(rows, { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + }) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1', 'original-execution-1'], + } + ) + + const body = await response.json() + + expect(body.rows).toEqual(rows) + }) + + it('should recursively materialize refs inside Response block manifest rows', async () => { + const text = 'nested'.repeat(2 * 1024 * 1024) + const nestedOutput = await compactExecutionPayload( + { text }, + { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + requireDurable: true, + preserveRoot: true, + } + ) + const nestedRef = (nestedOutput as unknown as { text: unknown }).text + const manifest = await createLargeArrayManifest([{ nested: nestedRef }], { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + }) + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + + const body = await response.json() + + expect(body.rows).toEqual([{ nested: text }]) + }) + + it('should recursively materialize refs inside stored Response block objects', async () => { + const text = 'nested'.repeat(2 * 1024 * 1024) + const nestedOutput = await compactExecutionPayload( + { text }, + { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + requireDurable: true, + preserveRoot: true, + } + ) + const nestedRef = (nestedOutput as unknown as { text: unknown }).text + const storedValue = { + wrapper: { + nested: nestedRef, + padding: 'x'.repeat(2048), + }, + } + const storedJson = JSON.stringify(storedValue) + const storedOutput = await storeLargeValue( + storedValue, + storedJson, + Buffer.byteLength(storedJson), + { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + requireDurable: true, + } + ) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: storedOutput, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + + const body = await response.json() + + expect(body.wrapper.nested).toEqual(text) + }) + + it('should memoize repeated materialized objects while resolving nested refs', async () => { + const text = 'nested'.repeat(2 * 1024 * 1024) + const nestedOutput = await compactExecutionPayload( + { text }, + { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + requireDurable: true, + preserveRoot: true, + } + ) + const nestedRef = (nestedOutput as unknown as { text: unknown }).text + const sourceValue = { nested: nestedRef } + const sourceJson = JSON.stringify(sourceValue) + const sourceRef = await storeLargeValue( + sourceValue, + sourceJson, + Buffer.byteLength(sourceJson), + { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + requireDurable: true, + } + ) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { first: sourceRef, second: sourceRef }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueKeys: sourceRef.key ? [sourceRef.key] : [], + } + ) + + const body = await response.json() + + expect(body).toEqual({ + first: { nested: text }, + second: { nested: text }, + }) + }) + + it('should materialize large string refs for Response block HTTP output', async () => { + const text = 'x'.repeat(9 * 1024 * 1024) + const output = await compactExecutionPayload( + { + data: { text }, + status: 200, + headers: {}, + }, + { + ...MATERIALIZATION_CONTEXT, + requireDurable: true, + preserveRoot: true, + } + ) + const response = await createHttpResponseFromBlock( + buildExecutionResult({ output }), + MATERIALIZATION_CONTEXT + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.text).toBe(text) + }) + + it('should reject Response block HTTP output that is too large to inline', async () => { + const output = await compactExecutionPayload( + { + data: { + text: 'x'.repeat(17 * 1024 * 1024), + }, + status: 200, + headers: {}, + }, + { + ...MATERIALIZATION_CONTEXT, + requireDurable: true, + preserveRoot: true, + } + ) + + await expect( + createHttpResponseFromBlock(buildExecutionResult({ output }), MATERIALIZATION_CONTEXT) + ).rejects.toMatchObject({ + code: EXECUTION_RESOURCE_LIMIT_CODE, + }) + }) }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 71403444637..dc35f7b6a93 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -399,7 +399,11 @@ async function handleExecutePost( // Resolve runFromBlock snapshot from executionId if needed let resolvedRunFromBlock: - | { startBlockId: string; sourceSnapshot: SerializableExecutionState } + | { + startBlockId: string + sourceSnapshot: SerializableExecutionState + sourceExecutionId?: string + } | undefined if (rawRunFromBlock) { if (rawRunFromBlock.sourceSnapshot && auth.authType === 'api_key') { @@ -424,13 +428,16 @@ async function handleExecutePost( sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState, } } else if (rawRunFromBlock.executionId) { - const { getExecutionStateForWorkflow, getLatestExecutionState } = await import( - '@/lib/workflows/executor/execution-state' - ) - const snapshot = + const { getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId } = + await import('@/lib/workflows/executor/execution-state') + const sourceExecution = rawRunFromBlock.executionId === 'latest' - ? await getLatestExecutionState(workflowId) - : await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId) + ? await getLatestExecutionStateWithExecutionId(workflowId) + : { + executionId: rawRunFromBlock.executionId, + state: await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId), + } + const snapshot = sourceExecution?.state if (!snapshot) { return NextResponse.json( { @@ -442,6 +449,7 @@ async function handleExecutePost( resolvedRunFromBlock = { startBlockId: rawRunFromBlock.startBlockId, sourceSnapshot: snapshot, + sourceExecutionId: sourceExecution.executionId, } } else { return NextResponse.json( @@ -687,6 +695,12 @@ async function handleExecutePost( const effectiveWorkflowStateOverride = sanitizedWorkflowStateOverride || cachedWorkflowData || undefined + const largeValueExecutionIds = [executionId] + const largeValueKeys: string[] = [] + const fileKeys: string[] = [] + const allowLargeValueWorkflowScope = Boolean( + resolvedRunFromBlock?.sourceSnapshot && !resolvedRunFromBlock.sourceExecutionId + ) if (!enableSSE) { reqLogger.info('Using non-SSE execution (direct JSON response)') @@ -705,6 +719,10 @@ async function handleExecutePost( isClientSession, enforceCredentialAccess: useAuthenticatedUserAsActor, workflowStateOverride: effectiveWorkflowStateOverride, + largeValueExecutionIds, + largeValueKeys, + fileKeys, + allowLargeValueWorkflowScope, callChain, executionMode: 'sync', } @@ -773,20 +791,47 @@ async function handleExecutePost( ) } + const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys + const outputFileKeys = result.metadata?.fileKeys ?? fileKeys + const outputWithBase64 = includeFileBase64 ? ((await hydrateUserFilesWithBase64(result.output, { requestId, workspaceId, workflowId, executionId, - allowLargeValueWorkflowScope: Boolean(resolvedRunFromBlock?.sourceSnapshot), + largeValueExecutionIds, + largeValueKeys: outputLargeValueKeys, + fileKeys: outputFileKeys, + allowLargeValueWorkflowScope, userId: actorUserId, maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, })) as NormalizedBlockOutput) : result.output if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(result)) { - return createHttpResponseFromBlock({ ...result, output: outputWithBase64 }) + const compactResponseBlockOutput = await compactRoutePayload(outputWithBase64, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: true, + preserveRoot: true, + }) + return await createHttpResponseFromBlock( + { ...result, output: compactResponseBlockOutput }, + { + workspaceId, + workflowId, + executionId, + largeValueExecutionIds, + largeValueKeys: outputLargeValueKeys, + fileKeys: outputFileKeys, + userId: actorUserId, + allowLargeValueWorkflowScope, + } + ) } const compactOutput = await compactRoutePayload(outputWithBase64, { @@ -884,10 +929,13 @@ async function handleExecutePost( timeoutMs: preprocessResult.executionTimeout?.sync, }, executionId, + largeValueExecutionIds, + largeValueKeys, + fileKeys, workspaceId, workflowId, userId: actorUserId, - allowLargeValueWorkflowScope: Boolean(resolvedRunFromBlock?.sourceSnapshot), + allowLargeValueWorkflowScope, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => executeWorkflow( streamWorkflow, @@ -906,6 +954,8 @@ async function handleExecutePost( base64MaxBytes, abortSignal, executionMode: 'stream', + largeValueKeys, + fileKeys, stopAfterBlockId, runFromBlock: resolvedRunFromBlock, }, @@ -1185,6 +1235,10 @@ async function handleExecutePost( isClientSession, enforceCredentialAccess: useAuthenticatedUserAsActor, workflowStateOverride: effectiveWorkflowStateOverride, + largeValueExecutionIds, + largeValueKeys, + fileKeys, + allowLargeValueWorkflowScope, callChain, executionMode: 'sync', } @@ -1309,15 +1363,22 @@ async function handleExecutePost( return } + const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys + const outputFileKeys = result.metadata?.fileKeys ?? fileKeys + const sseOutput = includeFileBase64 ? await hydrateUserFilesWithBase64(result.output, { requestId, workspaceId, workflowId, executionId, - allowLargeValueWorkflowScope: Boolean(resolvedRunFromBlock?.sourceSnapshot), + largeValueExecutionIds, + largeValueKeys: outputLargeValueKeys, + fileKeys: outputFileKeys, + allowLargeValueWorkflowScope, userId: actorUserId, maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, }) : result.output const compactSseOutput = await compactRoutePayload(sseOutput, { diff --git a/apps/sim/app/chat/components/auth/email/email-auth.tsx b/apps/sim/app/chat/components/auth/email/email-auth.tsx index 2515b92f4f4..3224e0bb4bb 100644 --- a/apps/sim/app/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/chat/components/auth/email/email-auth.tsx @@ -7,7 +7,7 @@ import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/c import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import AuthBackground from '@/app/(auth)/components/auth-background' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes' import { SupportFooter } from '@/app/(auth)/components/support-footer' import Navbar from '@/app/(landing)/components/navbar/navbar' import { useChatEmailOtpRequest, useChatEmailOtpVerify } from '@/hooks/queries/chats' @@ -123,7 +123,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
-

+

{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}

@@ -159,11 +159,11 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { className={cn( showEmailValidationError && emailErrors.length > 0 && - 'border-red-500 focus:border-red-500' + 'border-[var(--text-error)] focus:border-[var(--text-error)]' )} /> {showEmailValidationError && emailErrors.length > 0 && ( -

+
{emailErrors.map((error) => (

{error}

))} @@ -211,7 +211,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { ))} @@ -219,7 +219,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
{authError && ( -
+

{authError}

)} @@ -251,7 +251,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { ) : ( diff --git a/apps/sim/app/form/[identifier]/components/email-auth.tsx b/apps/sim/app/form/[identifier]/components/email-auth.tsx index b75cb159c3c..7fdc78b5aff 100644 --- a/apps/sim/app/form/[identifier]/components/email-auth.tsx +++ b/apps/sim/app/form/[identifier]/components/email-auth.tsx @@ -7,7 +7,7 @@ import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/c import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import AuthBackground from '@/app/(auth)/components/auth-background' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes' import { SupportFooter } from '@/app/(auth)/components/support-footer' import Navbar from '@/app/(landing)/components/navbar/navbar' import { useFormEmailOtpRequest, useFormEmailOtpVerify } from '@/hooks/queries/forms' @@ -120,7 +120,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
-

+

{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}

@@ -154,11 +154,11 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) { className={cn( showEmailValidationError && emailErrors.length > 0 && - 'border-red-500 focus:border-red-500' + 'border-[var(--text-error)] focus:border-[var(--text-error)]' )} /> {showEmailValidationError && emailErrors.length > 0 && ( -

+
{emailErrors.map((error) => (

{error}

))} @@ -206,7 +206,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) { ))} @@ -214,7 +214,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
{authError && ( -
+

{authError}

)} @@ -248,7 +248,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) { ) : ( diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts index cb91437f3c1..23e600614a0 100644 --- a/apps/sim/app/manifest.ts +++ b/apps/sim/app/manifest.ts @@ -18,7 +18,7 @@ export default function manifest(): MetadataRoute.Manifest { scope: '/', display: 'standalone', background_color: '#ffffff', - theme_color: brand.theme?.primaryColor || '#6F3DFA', + theme_color: brand.theme?.primaryColor || '#33C482', orientation: 'portrait-primary', icons: [ { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx index d2aae6bceb2..f5802447f10 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { Check, Clipboard } from 'lucide-react' @@ -81,7 +81,6 @@ interface A2aDeployProps { workflowNeedsRedeployment?: boolean onSubmittingChange?: (submitting: boolean) => void onCanSaveChange?: (canSave: boolean) => void - /** Callback for when republish status changes - depends on local form state */ onNeedsRepublishChange?: (needsRepublish: boolean) => void onDeployWorkflow?: () => Promise } @@ -147,13 +146,12 @@ export function A2aDeploy({ return missing }, [startBlockId, startBlockInputFormat]) - const handleAddA2AInputs = useCallback(() => { + const handleAddA2AInputs = () => { if (!startBlockId) return const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat) const newFields: InputFormatField[] = [] - // Add input field if missing (for TextPart) if (missingFields.input) { newFields.push({ id: generateId(), @@ -164,7 +162,6 @@ export function A2aDeploy({ }) } - // Add data field if missing (for DataPart) if (missingFields.data) { newFields.push({ id: generateId(), @@ -175,7 +172,6 @@ export function A2aDeploy({ }) } - // Add files field if missing (for FilePart) if (missingFields.files) { newFields.push({ id: generateId(), @@ -193,7 +189,7 @@ export function A2aDeploy({ `Added A2A input fields to Start block: ${newFields.map((f) => f.name).join(', ')}` ) } - }, [startBlockId, startBlockInputFormat, missingFields, collaborativeSetSubblockValue]) + } const [name, setName] = useState('') const [description, setDescription] = useState('') @@ -258,10 +254,7 @@ export function A2aDeploy({ workflowName, ]) - const hasWorkflowChanges = useMemo(() => { - if (!existingAgent) return false - return !!workflowNeedsRedeployment - }, [existingAgent, workflowNeedsRedeployment]) + const hasWorkflowChanges = existingAgent ? !!workflowNeedsRedeployment : false const needsRepublish = existingAgent && (hasFormChanges || hasWorkflowChanges) @@ -284,7 +277,7 @@ export function A2aDeploy({ onSubmittingChange?.(isSubmitting) }, [isSubmitting, onSubmittingChange]) - const handleCreateOrUpdate = useCallback(async () => { + const handleCreateOrUpdate = async () => { const capabilities: AgentCapabilities = { streaming: true, pushNotifications: pushNotificationsEnabled, @@ -319,20 +312,9 @@ export function A2aDeploy({ } catch (error) { logger.error('Failed to save A2A agent:', error) } - }, [ - existingAgent, - name, - description, - pushNotificationsEnabled, - authScheme, - skillTags, - workspaceId, - workflowId, - createAgent, - updateAgent, - ]) + } - const handlePublish = useCallback(async () => { + const handlePublish = async () => { if (!existingAgent) return try { await publishAgent.mutateAsync({ @@ -343,9 +325,9 @@ export function A2aDeploy({ } catch (error) { logger.error('Failed to publish A2A agent:', error) } - }, [existingAgent, workspaceId, publishAgent]) + } - const handleUnpublish = useCallback(async () => { + const handleUnpublish = async () => { if (!existingAgent) return try { await publishAgent.mutateAsync({ @@ -356,9 +338,9 @@ export function A2aDeploy({ } catch (error) { logger.error('Failed to unpublish A2A agent:', error) } - }, [existingAgent, workspaceId, publishAgent]) + } - const handleDelete = useCallback(async () => { + const handleDelete = async () => { if (!existingAgent) return try { await deleteAgent.mutateAsync({ @@ -370,9 +352,9 @@ export function A2aDeploy({ } catch (error) { logger.error('Failed to delete A2A agent:', error) } - }, [existingAgent, workspaceId, deleteAgent, workflowName, workflowDescription]) + } - const handlePublishNewAgent = useCallback(async () => { + const handlePublishNewAgent = async () => { const capabilities: AgentCapabilities = { streaming: true, pushNotifications: pushNotificationsEnabled, @@ -406,21 +388,9 @@ export function A2aDeploy({ } catch (error) { logger.error('Failed to publish A2A agent:', error) } - }, [ - name, - description, - pushNotificationsEnabled, - authScheme, - skillTags, - workspaceId, - workflowId, - createAgent, - publishAgent, - isDeployed, - onDeployWorkflow, - ]) + } - const handleUpdateAndRepublish = useCallback(async () => { + const handleUpdateAndRepublish = async () => { if (!existingAgent) return const capabilities: AgentCapabilities = { @@ -455,20 +425,7 @@ export function A2aDeploy({ } catch (error) { logger.error('Failed to update and republish A2A agent:', error) } - }, [ - existingAgent, - isDeployed, - workflowNeedsRedeployment, - onDeployWorkflow, - name, - description, - pushNotificationsEnabled, - authScheme, - skillTags, - workspaceId, - updateAgent, - publishAgent, - ]) + } const baseUrl = getBaseUrl() const endpoint = existingAgent ? `${baseUrl}/api/a2a/serve/${existingAgent.id}` : null @@ -484,7 +441,7 @@ export function A2aDeploy({ ) }, [startBlockInputFormat]) - const getExampleInputData = useCallback((): Record => { + const getExampleInputData = (): Record => { const data: Record = {} for (const field of additionalInputFields) { switch (field.type) { @@ -508,13 +465,12 @@ export function A2aDeploy({ } } return data - }, [additionalInputFields]) + } - const getJsonRpcPayload = useCallback((): Record => { + const getJsonRpcPayload = (): Record => { const inputData = getExampleInputData() const hasAdditionalData = Object.keys(inputData).length > 0 - // Build parts array: TextPart for message text, DataPart for additional fields const parts: Array> = [{ kind: 'text', text: 'Hello, agent!' }] if (hasAdditionalData) { parts.push({ kind: 'data', data: inputData }) @@ -531,9 +487,9 @@ export function A2aDeploy({ }, }, } - }, [getExampleInputData, useStreamingExample]) + } - const getCurlCommand = useCallback((): string => { + const getCurlCommand = (): string => { if (!endpoint) return '' const payload = getJsonRpcPayload() const requiresAuth = authScheme !== 'none' @@ -623,13 +579,13 @@ console.log(data);` default: return '' } - }, [endpoint, language, getJsonRpcPayload, authScheme]) + } - const handleCopyCommand = useCallback(() => { + const handleCopyCommand = () => { navigator.clipboard.writeText(getCurlCommand()) setCodeCopied(true) setTimeout(() => setCodeCopied(false), 2000) - }, [getCurlCommand]) + } if (isLoading) { return ( @@ -664,7 +620,6 @@ console.log(data);` }} className='-mx-1 space-y-3 overflow-y-auto px-1 pb-4' > - {/* Endpoint URL (shown when agent exists) */} {existingAgent && endpoint && (
@@ -692,15 +647,15 @@ console.log(data);`
-
-
+
+
{baseUrl.replace(/^https?:\/\//, '')}/api/a2a/serve/
@@ -710,13 +665,12 @@ console.log(data);`
)} - {/* Agent Name */}
- {/* Description */}