diff --git a/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx b/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx index 04f210ad17b..5e047457ae5 100644 --- a/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx +++ b/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx @@ -126,26 +126,38 @@ Access resume data in downstream blocks using ``. - **Stream mode** (`stream: true` on the original execute call) — The resume response streams SSE events with `selectedOutputs` chunks, just like the initial execution. - - **Async mode** (`X-Execution-Mode: async` on the original execute call) — The resume dispatches execution to a background worker and returns immediately with `202`: + - **Async mode** (`X-Execution-Mode: async` on the original execute call) — The resume dispatches execution to a background worker and returns immediately with `202`, including a `jobId` and `statusUrl` for polling: ```json { - "status": "started", + "success": true, + "async": true, + "jobId": "", "executionId": "", - "message": "Resume execution started asynchronously." + "message": "Resume execution queued", + "statusUrl": "/api/jobs/" } ``` #### Polling execution status - To check on a paused execution or poll for completion after an async resume: + Poll the `statusUrl` from the async response to check when the resume completes: + + ```bash + GET /api/jobs/{jobId} + X-API-Key: your-api-key + ``` + + Returns job status and, when completed, the full workflow output. + + To check on a paused execution's pause points and resume links: ```bash GET /api/resume/{workflowId}/{executionId} X-API-Key: your-api-key ``` - Returns the full paused execution detail with all pause points, their statuses, and resume links. Returns `404` when the execution has completed and is no longer paused. + Returns the paused execution detail with all pause points, their statuses, and resume links. Returns `404` when the execution has completed and is no longer paused. ### Webhook diff --git a/apps/docs/content/docs/en/tools/jira_service_management.mdx b/apps/docs/content/docs/en/tools/jira_service_management.mdx index 533acee20ad..f2c0ed2020d 100644 --- a/apps/docs/content/docs/en/tools/jira_service_management.mdx +++ b/apps/docs/content/docs/en/tools/jira_service_management.mdx @@ -678,4 +678,84 @@ Get the fields required to create a request of a specific type in Jira Service M | ↳ `defaultValues` | json | Default values for the field | | ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId | +### `jsm_get_form_templates` + +List forms (ProForma/JSM Forms) in a Jira project to discover form IDs for request types + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `projectIdOrKey` | string | Yes | Jira project ID or key \(e.g., "10001" or "SD"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `projectIdOrKey` | string | Project ID or key | +| `templates` | array | List of forms in the project | +| ↳ `id` | string | Form template ID \(UUID\) | +| ↳ `name` | string | Form template name | +| ↳ `updated` | string | Last updated timestamp \(ISO 8601\) | +| ↳ `issueCreateIssueTypeIds` | json | Issue type IDs that auto-attach this form on issue create | +| ↳ `issueCreateRequestTypeIds` | json | Request type IDs that auto-attach this form on issue create | +| ↳ `portalRequestTypeIds` | json | Request type IDs that show this form on the customer portal | +| ↳ `recommendedIssueRequestTypeIds` | json | Request type IDs that recommend this form | +| `total` | number | Total number of forms | + +### `jsm_get_form_structure` + +Get the full structure of a ProForma/JSM form including all questions, field types, choices, layout, and conditions + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `projectIdOrKey` | string | Yes | Jira project ID or key \(e.g., "10001" or "SD"\) | +| `formId` | string | Yes | Form ID \(UUID from Get Form Templates\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `projectIdOrKey` | string | Project ID or key | +| `formId` | string | Form ID | +| `design` | json | Full form design with questions \(field types, labels, choices, validation\), layout \(field ordering\), and conditions | +| `updated` | string | Last updated timestamp | +| `publish` | json | Publishing and request type configuration | + +### `jsm_get_issue_forms` + +List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., "SD-123", "10001"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `forms` | array | List of forms attached to the issue | +| ↳ `id` | string | Form instance ID \(UUID\) | +| ↳ `name` | string | Form name | +| ↳ `updated` | string | Last updated timestamp \(ISO 8601\) | +| ↳ `submitted` | boolean | Whether the form has been submitted | +| ↳ `lock` | boolean | Whether the form is locked | +| ↳ `internal` | boolean | Whether the form is internal-only | +| ↳ `formTemplateId` | string | Source form template ID \(UUID\) | +| `total` | number | Total number of forms | + diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 2e8faa2464f..febad336bcc 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -25,6 +25,10 @@ "name": "Workflows", "description": "Execute workflows and manage workflow resources" }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, { "name": "Logs", "description": "Query execution logs and retrieve details" @@ -207,30 +211,568 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Whether the cancellation was successful." - }, - "executionId": { - "type": "string", - "description": "The ID of the cancelled execution." - } - } + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "exec_abc123" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "wf_1a2b3c4d5e" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "wf_1a2b3c4d5e", + "executionId": "exec_9f8e7d6c5b", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b", + "contextId": "ctx_xyz789", + "executionId": "exec_9f8e7d6c5b", + "workflowId": "wf_1a2b3c4d5e" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "wf_1a2b3c4d5e" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "exec_9f8e7d6c5b" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "wf_1a2b3c4d5e" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "exec_9f8e7d6c5b" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "wf_1a2b3c4d5e" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "exec_9f8e7d6c5b" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "wf_1a2b3c4d5e" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "exec_9f8e7d6c5b" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "exec_new123", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "exec_new123", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "exec_new123", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" }, "example": { "success": true, - "executionId": "exec_abc123" + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "exec_new123", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "$ref": "#/components/responses/NotFound" + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } } } } @@ -5788,6 +6330,346 @@ "description": "Upper bound value for 'between' operator." } } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } } }, "responses": { diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index a05fcbb7eff..d367a801885 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -6614,9 +6614,21 @@ { "name": "Get Request Type Fields", "description": "Get the fields required to create a request of a specific type in Jira Service Management" + }, + { + "name": "Get Form Templates", + "description": "List forms (ProForma/JSM Forms) in a Jira project to discover form IDs for request types" + }, + { + "name": "Get Form Structure", + "description": "Get the full structure of a ProForma/JSM form including all questions, field types, choices, layout, and conditions" + }, + { + "name": "Get Issue Forms", + "description": "List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock)" } ], - "operationCount": 21, + "operationCount": 24, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -10784,8 +10796,34 @@ } ], "operationCount": 4, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "servicenow_incident_created", + "name": "ServiceNow Incident Created", + "description": "Trigger workflow when a new incident is created in ServiceNow" + }, + { + "id": "servicenow_incident_updated", + "name": "ServiceNow Incident Updated", + "description": "Trigger workflow when an incident is updated in ServiceNow" + }, + { + "id": "servicenow_change_request_created", + "name": "ServiceNow Change Request Created", + "description": "Trigger workflow when a new change request is created in ServiceNow" + }, + { + "id": "servicenow_change_request_updated", + "name": "ServiceNow Change Request Updated", + "description": "Trigger workflow when a change request is updated in ServiceNow" + }, + { + "id": "servicenow_webhook", + "name": "ServiceNow Webhook (All Events)", + "description": "Trigger workflow on any ServiceNow webhook event" + } + ], + "triggerCount": 5, "authType": "none", "category": "tools", "integrationType": "customer-support", diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index beee4876b03..2ecc89fcfbb 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -246,31 +246,10 @@ export async function POST( ), }) - // For forms, we don't stream back - we wait for completion and return success - // Consume the stream to wait for completion const reader = stream.getReader() - let lastOutput: any = null - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - // Parse SSE data if present - const text = new TextDecoder().decode(value) - const lines = text.split('\n') - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)) - if (data.type === 'complete' || data.output) { - lastOutput = data.output || data - } - } catch { - // Ignore parse errors - } - } - } + while (!(await reader.read()).done) { + /* drain to let the workflow run to completion */ } } finally { reader.releaseLock() diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 8c060d9d13e..f6a33f823e7 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -194,7 +194,7 @@ export async function POST( }) } - if (isApiCaller && executionMode !== 'async') { + if (isApiCaller && executionMode === 'sync') { const result = await PauseResumeManager.startResumeExecution(resumeArgs) return NextResponse.json({ diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 0d1e41a9e1f..176103d682f 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -8,6 +8,7 @@ import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch' +import { getWorkflowById } from '@/lib/workflows/utils' import { executeJobInline, executeScheduleJob, @@ -115,7 +116,6 @@ export async function GET(request: NextRequest) { } try { - const { getWorkflowById } = await import('@/lib/workflows/utils') const resolvedWorkflow = schedule.workflowId ? await getWorkflowById(schedule.workflowId) : null diff --git a/apps/sim/app/api/tools/jsm/forms/issue/route.ts b/apps/sim/app/api/tools/jsm/forms/issue/route.ts new file mode 100644 index 00000000000..e6f95490b17 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/issue/route.ts @@ -0,0 +1,115 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { + getJiraCloudId, + getJsmFormsApiBaseUrl, + getJsmHeaders, + parseJsmErrorMessage, +} from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmIssueFormsAPI') + +export async function POST(request: NextRequest) { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body + + if (!domain) { + logger.error('Missing domain in request') + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + if (!accessToken) { + logger.error('Missing access token in request') + return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) + } + + if (!issueIdOrKey) { + logger.error('Missing issueIdOrKey in request') + return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 }) + } + + const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken)) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form` + + logger.info('Fetching issue forms from:', { url, issueIdOrKey }) + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseJsmErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + const forms = Array.isArray(data) ? data : (data.values ?? data.forms ?? []) + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + forms: forms.map((form: Record) => ({ + id: form.id ?? null, + name: form.name ?? null, + updated: form.updated ?? null, + submitted: form.submitted ?? false, + lock: form.lock ?? false, + internal: form.internal ?? null, + formTemplateId: (form.formTemplate as Record)?.id ?? null, + })), + total: forms.length, + }, + }) + } catch (error) { + logger.error('Error fetching issue forms:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + success: false, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/structure/route.ts b/apps/sim/app/api/tools/jsm/forms/structure/route.ts new file mode 100644 index 00000000000..2958687dab3 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/structure/route.ts @@ -0,0 +1,117 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { + getJiraCloudId, + getJsmFormsApiBaseUrl, + getJsmHeaders, + parseJsmErrorMessage, +} from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmFormStructureAPI') + +export async function POST(request: NextRequest) { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey, formId } = body + + if (!domain) { + logger.error('Missing domain in request') + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + if (!accessToken) { + logger.error('Missing access token in request') + return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) + } + + if (!projectIdOrKey) { + logger.error('Missing projectIdOrKey in request') + return NextResponse.json({ error: 'Project ID or key is required' }, { status: 400 }) + } + + if (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID is required' }, { status: 400 }) + } + + const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken)) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const projectIdOrKeyValidation = validateJiraIssueKey(projectIdOrKey, 'projectIdOrKey') + if (!projectIdOrKeyValidation.isValid) { + return NextResponse.json({ error: projectIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/project/${encodeURIComponent(projectIdOrKey)}/form/${encodeURIComponent(formId)}` + + logger.info('Fetching form template from:', { url, projectIdOrKey, formId }) + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseJsmErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + projectIdOrKey, + formId, + design: data.design ?? null, + updated: data.updated ?? null, + publish: data.publish ?? null, + }, + }) + } catch (error) { + logger.error('Error fetching form structure:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + success: false, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/templates/route.ts b/apps/sim/app/api/tools/jsm/forms/templates/route.ts new file mode 100644 index 00000000000..dc33e8bc5c9 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/templates/route.ts @@ -0,0 +1,115 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { + getJiraCloudId, + getJsmFormsApiBaseUrl, + getJsmHeaders, + parseJsmErrorMessage, +} from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmFormTemplatesAPI') + +export async function POST(request: NextRequest) { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey } = body + + if (!domain) { + logger.error('Missing domain in request') + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + if (!accessToken) { + logger.error('Missing access token in request') + return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) + } + + if (!projectIdOrKey) { + logger.error('Missing projectIdOrKey in request') + return NextResponse.json({ error: 'Project ID or key is required' }, { status: 400 }) + } + + const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken)) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const projectIdOrKeyValidation = validateJiraIssueKey(projectIdOrKey, 'projectIdOrKey') + if (!projectIdOrKeyValidation.isValid) { + return NextResponse.json({ error: projectIdOrKeyValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/project/${encodeURIComponent(projectIdOrKey)}/form` + + logger.info('Fetching project form templates from:', { url, projectIdOrKey }) + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseJsmErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + const templates = Array.isArray(data) ? data : (data.values ?? []) + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + projectIdOrKey, + templates: templates.map((template: Record) => ({ + id: template.id ?? null, + name: template.name ?? null, + updated: template.updated ?? null, + issueCreateIssueTypeIds: template.issueCreateIssueTypeIds ?? [], + issueCreateRequestTypeIds: template.issueCreateRequestTypeIds ?? [], + portalRequestTypeIds: template.portalRequestTypeIds ?? [], + recommendedIssueRequestTypeIds: template.recommendedIssueRequestTypeIds ?? [], + })), + total: templates.length, + }, + }) + } catch (error) { + logger.error('Error fetching form templates:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + success: false, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.ts index d314e8563bb..053d328b0dd 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.ts @@ -20,9 +20,6 @@ export async function GET( const { provider } = await params const requestId = generateShortId() - const LOCK_KEY = `${provider}-polling-lock` - let lockValue: string | undefined - try { const authError = verifyCronAuth(request, `${provider} webhook polling`) if (authError) return authError @@ -31,29 +28,38 @@ export async function GET( return NextResponse.json({ error: `Unknown polling provider: ${provider}` }, { status: 404 }) } - lockValue = requestId - const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS) - if (!locked) { - return NextResponse.json( - { - success: true, - message: 'Polling already in progress – skipped', - requestId, - status: 'skip', - }, - { status: 202 } - ) - } + const LOCK_KEY = `${provider}-polling-lock` + let lockValue: string | undefined + + try { + lockValue = requestId + const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS) + if (!locked) { + return NextResponse.json( + { + success: true, + message: 'Polling already in progress – skipped', + requestId, + status: 'skip', + }, + { status: 202 } + ) + } - const results = await pollProvider(provider) + const results = await pollProvider(provider) - return NextResponse.json({ - success: true, - message: `${provider} polling completed`, - requestId, - status: 'completed', - ...results, - }) + return NextResponse.json({ + success: true, + message: `${provider} polling completed`, + requestId, + status: 'completed', + ...results, + }) + } finally { + if (lockValue) { + await releaseLock(LOCK_KEY, lockValue).catch(() => {}) + } + } } catch (error) { logger.error(`Error during ${provider} polling (${requestId}):`, error) return NextResponse.json( @@ -65,9 +71,5 @@ export async function GET( }, { status: 500 } ) - } finally { - if (lockValue) { - await releaseLock(LOCK_KEY, lockValue).catch(() => {}) - } } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx index 0501948f850..510a4996992 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx @@ -89,7 +89,7 @@ export function VersionDescriptionModal({ return ( <> !openState && handleCloseAttempt()}> - + Version Description diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 68e8a357e9b..fd0cc4b84d7 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -44,6 +44,9 @@ export const JiraServiceManagementBlock: BlockConfig = { { label: 'Get Approvals', id: 'get_approvals' }, { label: 'Answer Approval', id: 'answer_approval' }, { label: 'Get Request Type Fields', id: 'get_request_type_fields' }, + { label: 'Get Form Templates', id: 'get_form_templates' }, + { label: 'Get Form Structure', id: 'get_form_structure' }, + { label: 'Get Issue Forms', id: 'get_issue_forms' }, ], value: () => 'get_service_desks', }, @@ -191,9 +194,26 @@ export const JiraServiceManagementBlock: BlockConfig = { 'add_participants', 'get_approvals', 'answer_approval', + 'get_issue_forms', ], }, }, + { + id: 'projectIdOrKey', + title: 'Project ID or Key', + type: 'short-input', + required: { field: 'operation', value: ['get_form_templates', 'get_form_structure'] }, + placeholder: 'Enter Jira project ID or key (e.g., 10001 or SD)', + condition: { field: 'operation', value: ['get_form_templates', 'get_form_structure'] }, + }, + { + id: 'formId', + title: 'Form ID', + type: 'short-input', + required: true, + placeholder: 'Enter form ID (UUID from Get Form Templates)', + condition: { field: 'operation', value: 'get_form_structure' }, + }, { id: 'summary', title: 'Summary', @@ -503,6 +523,9 @@ Return ONLY the comment text - no explanations.`, 'jsm_get_approvals', 'jsm_answer_approval', 'jsm_get_request_type_fields', + 'jsm_get_form_templates', + 'jsm_get_form_structure', + 'jsm_get_issue_forms', ], config: { tool: (params) => { @@ -549,6 +572,12 @@ Return ONLY the comment text - no explanations.`, return 'jsm_answer_approval' case 'get_request_type_fields': return 'jsm_get_request_type_fields' + case 'get_form_templates': + return 'jsm_get_form_templates' + case 'get_form_structure': + return 'jsm_get_form_structure' + case 'get_issue_forms': + return 'jsm_get_issue_forms' default: return 'jsm_get_service_desks' } @@ -808,6 +837,34 @@ Return ONLY the comment text - no explanations.`, serviceDeskId: params.serviceDeskId, requestTypeId: params.requestTypeId, } + case 'get_form_templates': + if (!params.projectIdOrKey) { + throw new Error('Project ID or key is required') + } + return { + ...baseParams, + projectIdOrKey: params.projectIdOrKey, + } + case 'get_form_structure': + if (!params.projectIdOrKey) { + throw new Error('Project ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + projectIdOrKey: params.projectIdOrKey, + formId: params.formId, + } + case 'get_issue_forms': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + } default: return baseParams } @@ -857,6 +914,8 @@ Return ONLY the comment text - no explanations.`, type: 'string', description: 'JSON object of form answers for form-based request types', }, + projectIdOrKey: { type: 'string', description: 'Jira project ID or key' }, + formId: { type: 'string', description: 'Form ID (UUID)' }, searchQuery: { type: 'string', description: 'Filter request types by name' }, groupId: { type: 'string', description: 'Filter by request type group ID' }, expand: { type: 'string', description: 'Comma-separated fields to expand' }, @@ -899,5 +958,25 @@ Return ONLY the comment text - no explanations.`, type: 'boolean', description: 'Whether requests can be raised on behalf of another user', }, + templates: { + type: 'json', + description: + 'Array of form templates (id, name, updated, portalRequestTypeIds, issueCreateIssueTypeIds)', + }, + design: { + type: 'json', + description: + 'Full form design with questions (labels, types, choices, validation), layout, conditions, sections, settings', + }, + publish: { + type: 'json', + description: 'Form publishing and request type configuration', + }, + updated: { type: 'string', description: 'Last updated timestamp' }, + forms: { + type: 'json', + description: + 'Array of forms attached to an issue (id, name, updated, submitted, lock, internal, formTemplateId)', + }, }, } diff --git a/apps/sim/blocks/blocks/servicenow.ts b/apps/sim/blocks/blocks/servicenow.ts index 14376584725..06e7249f302 100644 --- a/apps/sim/blocks/blocks/servicenow.ts +++ b/apps/sim/blocks/blocks/servicenow.ts @@ -2,6 +2,7 @@ import { ServiceNowIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { IntegrationType } from '@/blocks/types' import type { ServiceNowResponse } from '@/tools/servicenow/types' +import { getTrigger } from '@/triggers' export const ServiceNowBlock: BlockConfig = { type: 'servicenow', @@ -215,6 +216,11 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st condition: { field: 'operation', value: 'servicenow_delete_record' }, required: true, }, + ...getTrigger('servicenow_incident_created').subBlocks, + ...getTrigger('servicenow_incident_updated').subBlocks, + ...getTrigger('servicenow_change_request_created').subBlocks, + ...getTrigger('servicenow_change_request_updated').subBlocks, + ...getTrigger('servicenow_webhook').subBlocks, ], tools: { access: [ @@ -262,4 +268,14 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st success: { type: 'boolean', description: 'Operation success status' }, metadata: { type: 'json', description: 'Operation metadata' }, }, + triggers: { + enabled: true, + available: [ + 'servicenow_incident_created', + 'servicenow_incident_updated', + 'servicenow_change_request_created', + 'servicenow_change_request_updated', + 'servicenow_webhook', + ], + }, } diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts index 5c639fc16c8..e1b104fbb75 100644 --- a/apps/sim/hooks/queries/credential-sets.ts +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -157,7 +157,7 @@ export function useAcceptCredentialSetInvitation() { } return response.json() }, - onSuccess: () => { + onSettled: () => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() }) }, @@ -187,7 +187,7 @@ export function useCreateCredentialSet() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId) }) }, }) @@ -209,7 +209,7 @@ export function useCreateCredentialSetInvitation() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId), }) @@ -264,7 +264,7 @@ export function useRemoveCredentialSetMember() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.detailMembers(variables.credentialSetId), }) @@ -288,7 +288,7 @@ export function useLeaveCredentialSet() { } return response.json() }, - onSuccess: () => { + onSettled: () => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) }, }) @@ -313,7 +313,7 @@ export function useDeleteCredentialSet() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId), }) @@ -370,7 +370,7 @@ export function useCancelCredentialSetInvitation() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId), }) @@ -393,7 +393,7 @@ export function useResendCredentialSetInvitation() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId), }) diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 9267759209f..708554aa965 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -5,6 +5,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { environmentKeys } from '@/hooks/queries/environment' import { fetchJson } from '@/hooks/selectors/helpers' +/** + * Key prefix for OAuth credential queries. + * Duplicated here to avoid circular imports with oauth-credentials.ts. + */ +const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const + export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' export type WorkspaceCredentialRole = 'admin' | 'member' export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked' @@ -192,6 +198,9 @@ export function useCreateWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists(), }) + queryClient.invalidateQueries({ + queryKey: OAUTH_CREDENTIALS_KEY, + }) }, }) } @@ -269,6 +278,9 @@ export function useUpdateWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists(), }) + queryClient.invalidateQueries({ + queryKey: OAUTH_CREDENTIALS_KEY, + }) }, }) } @@ -290,6 +302,7 @@ export function useDeleteWorkspaceCredential() { onSettled: (_data, _error, credentialId) => { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) }) queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }) + queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY }) queryClient.invalidateQueries({ queryKey: environmentKeys.all }) }, }) diff --git a/apps/sim/lib/webhooks/polling/gmail.ts b/apps/sim/lib/webhooks/polling/gmail.ts index 7db8587d2c2..7ca379194f2 100644 --- a/apps/sim/lib/webhooks/polling/gmail.ts +++ b/apps/sim/lib/webhooks/polling/gmail.ts @@ -151,44 +151,68 @@ async function fetchNewEmails( let latestHistoryId = config.historyId if (useHistoryApi) { - const historyUrl = `https://gmail.googleapis.com/gmail/v1/users/me/history?startHistoryId=${config.historyId}` + const messageIds = new Set() + let pageToken: string | undefined - const historyResponse = await fetch(historyUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) + do { + let historyUrl = `https://gmail.googleapis.com/gmail/v1/users/me/history?startHistoryId=${config.historyId}&historyTypes=messageAdded` + if (pageToken) { + historyUrl += `&pageToken=${pageToken}` + } - if (!historyResponse.ok) { - const errorData = await historyResponse.json() - logger.error(`[${requestId}] Gmail history API error:`, { - status: historyResponse.status, - statusText: historyResponse.statusText, - error: errorData, + const historyResponse = await fetch(historyUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, }) - logger.info(`[${requestId}] Falling back to search API after history API failure`) - return searchEmails(accessToken, config, requestId, logger) - } + if (!historyResponse.ok) { + const status = historyResponse.status + const errorData = await historyResponse.json().catch(() => ({})) + logger.error(`[${requestId}] Gmail history API error:`, { + status, + statusText: historyResponse.statusText, + error: errorData, + }) + + if (status === 403 || status === 429) { + throw new Error( + `Gmail API error ${status} — skipping to retry next poll cycle: ${JSON.stringify(errorData)}` + ) + } - const historyData = await historyResponse.json() + logger.info(`[${requestId}] Falling back to search API after history API error ${status}`) + const searchResult = await searchEmails(accessToken, config, requestId, logger) + if (searchResult.emails.length === 0) { + const freshHistoryId = await getGmailProfileHistoryId(accessToken, requestId, logger) + if (freshHistoryId) { + logger.info( + `[${requestId}] Fetched fresh historyId ${freshHistoryId} after invalid historyId (was: ${config.historyId})` + ) + return { emails: [], latestHistoryId: freshHistoryId } + } + } + return searchResult + } - if (!historyData.history || !historyData.history.length) { - return { emails: [], latestHistoryId } - } + const historyData = await historyResponse.json() - if (historyData.historyId) { - latestHistoryId = historyData.historyId - } + if (historyData.historyId) { + latestHistoryId = historyData.historyId + } - const messageIds = new Set() - for (const history of historyData.history) { - if (history.messagesAdded) { - for (const messageAdded of history.messagesAdded) { - messageIds.add(messageAdded.message.id) + if (historyData.history) { + for (const history of historyData.history) { + if (history.messagesAdded) { + for (const messageAdded of history.messagesAdded) { + messageIds.add(messageAdded.message.id) + } + } } } - } - if (messageIds.size === 0) { + pageToken = historyData.nextPageToken + } while (pageToken) + + if (!messageIds.size) { return { emails: [], latestHistoryId } } @@ -352,6 +376,29 @@ async function searchEmails( } } +async function getGmailProfileHistoryId( + accessToken: string, + requestId: string, + logger: ReturnType +): Promise { + try { + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/profile', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok) { + logger.warn( + `[${requestId}] Failed to fetch Gmail profile for fresh historyId: ${response.status}` + ) + return null + } + const profile = await response.json() + return (profile.historyId as string | undefined) ?? null + } catch (error) { + logger.warn(`[${requestId}] Error fetching Gmail profile:`, error) + return null + } +} + async function getEmailDetails(accessToken: string, messageId: string): Promise { const messageUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full` @@ -442,9 +489,7 @@ async function processEmails( if (headers.date) { try { date = new Date(headers.date).toISOString() - } catch (_e) { - // Keep date as null if parsing fails - } + } catch (_e) {} } else if (email.internalDate) { date = new Date(Number.parseInt(email.internalDate)).toISOString() } diff --git a/apps/sim/lib/webhooks/polling/imap.ts b/apps/sim/lib/webhooks/polling/imap.ts index e5822aa8882..f82a8bb0bbb 100644 --- a/apps/sim/lib/webhooks/polling/imap.ts +++ b/apps/sim/lib/webhooks/polling/imap.ts @@ -22,6 +22,7 @@ interface ImapWebhookConfig { includeAttachments: boolean lastProcessedUid?: number lastProcessedUidByMailbox?: Record + uidValidityByMailbox?: Record lastCheckedTimestamp?: string maxEmailsPerPoll?: number } @@ -90,48 +91,90 @@ export const imapPollingHandler: PollingProviderHandler = { return 'failure' } - const { emails, latestUidByMailbox } = await fetchNewEmails( - config, - requestId, - hostValidation.resolvedIP!, - logger - ) - const pollTimestamp = new Date().toISOString() + const client = new ImapFlow({ + host: hostValidation.resolvedIP!, + servername: config.host, + port: config.port || 993, + secure: config.secure ?? true, + auth: { + user: config.username, + pass: config.password, + }, + tls: { rejectUnauthorized: true }, + logger: false, + }) + + let emails: Awaited>['emails'] = [] + let latestUidByMailbox: Record = {} + let uidValidityByMailbox: Record = {} - if (!emails || !emails.length) { - await updateImapState(webhookId, latestUidByMailbox, pollTimestamp, config, logger) - await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No new emails found for webhook ${webhookId}`) - return 'success' - } + try { + await client.connect() + + const result = await fetchNewEmails(client, config, requestId, logger) + emails = result.emails + latestUidByMailbox = result.latestUidByMailbox + uidValidityByMailbox = result.uidValidityByMailbox + + const pollTimestamp = new Date().toISOString() + + if (!emails.length) { + await updateImapState( + webhookId, + latestUidByMailbox, + pollTimestamp, + config, + logger, + uidValidityByMailbox + ) + await markWebhookSuccess(webhookId, logger) + logger.info(`[${requestId}] No new emails found for webhook ${webhookId}`) + await client.logout() + return 'success' + } - logger.info(`[${requestId}] Found ${emails.length} new emails for webhook ${webhookId}`) + logger.info(`[${requestId}] Found ${emails.length} new emails for webhook ${webhookId}`) - const { processedCount, failedCount } = await processEmails( - emails, - webhookData, - workflowData, - config, - requestId, - hostValidation.resolvedIP!, - logger - ) + const { processedCount, failedCount } = await processEmails( + emails, + webhookData, + workflowData, + config, + client, + requestId, + logger + ) + + await updateImapState( + webhookId, + latestUidByMailbox, + pollTimestamp, + config, + logger, + uidValidityByMailbox + ) - await updateImapState(webhookId, latestUidByMailbox, pollTimestamp, config, logger) + await client.logout() - if (failedCount > 0 && processedCount === 0) { - await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} emails failed to process for webhook ${webhookId}` + if (failedCount > 0 && processedCount === 0) { + await markWebhookFailed(webhookId, logger) + logger.warn( + `[${requestId}] All ${failedCount} emails failed to process for webhook ${webhookId}` + ) + return 'failure' + } + + await markWebhookSuccess(webhookId, logger) + logger.info( + `[${requestId}] Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) - return 'failure' + return 'success' + } catch (innerError) { + try { + await client.logout() + } catch {} + throw innerError } - - await markWebhookSuccess(webhookId, logger) - logger.info( - `[${requestId}] Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` - ) - return 'success' } catch (error) { logger.error(`[${requestId}] Error processing IMAP webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) @@ -145,13 +188,35 @@ async function updateImapState( uidByMailbox: Record, timestamp: string, config: ImapWebhookConfig, - logger: ReturnType + logger: ReturnType, + uidValidityByMailbox: Record ) { const existingUidByMailbox = config.lastProcessedUidByMailbox || {} - const mergedUidByMailbox = { ...existingUidByMailbox } + const prevUidValidity = config.uidValidityByMailbox || {} + + const resetMailboxes = new Set( + Object.entries(uidValidityByMailbox) + .filter( + ([mailbox, validity]) => + prevUidValidity[mailbox] !== undefined && prevUidValidity[mailbox] !== validity + ) + .map(([mailbox]) => mailbox) + ) + + const mergedUidByMailbox: Record = {} + + for (const [mailbox, uid] of Object.entries(existingUidByMailbox)) { + if (!resetMailboxes.has(mailbox)) { + mergedUidByMailbox[mailbox] = uid + } + } for (const [mailbox, uid] of Object.entries(uidByMailbox)) { - mergedUidByMailbox[mailbox] = Math.max(uid, mergedUidByMailbox[mailbox] || 0) + if (resetMailboxes.has(mailbox)) { + mergedUidByMailbox[mailbox] = uid + } else { + mergedUidByMailbox[mailbox] = Math.max(uid, mergedUidByMailbox[mailbox] || 0) + } } await updateWebhookProviderConfig( @@ -159,30 +224,18 @@ async function updateImapState( { lastProcessedUidByMailbox: mergedUidByMailbox, lastCheckedTimestamp: timestamp, + uidValidityByMailbox, }, logger ) } async function fetchNewEmails( + client: ImapFlow, config: ImapWebhookConfig, requestId: string, - resolvedIP: string, logger: ReturnType ) { - const client = new ImapFlow({ - host: resolvedIP, - servername: config.host, - port: config.port || 993, - secure: config.secure ?? true, - auth: { - user: config.username, - pass: config.password, - }, - tls: { rejectUnauthorized: true }, - logger: false, - }) - const emails: Array<{ uid: number mailboxPath: string @@ -193,97 +246,93 @@ async function fetchNewEmails( const mailboxes = getMailboxesToCheck(config) const latestUidByMailbox: Record = { ...(config.lastProcessedUidByMailbox || {}) } + const uidValidityByMailbox: Record = { ...(config.uidValidityByMailbox || {}) } - try { - await client.connect() - - const maxEmails = config.maxEmailsPerPoll || 25 - let totalEmailsCollected = 0 + const maxEmails = config.maxEmailsPerPoll || 25 + let totalEmailsCollected = 0 - for (const mailboxPath of mailboxes) { - if (totalEmailsCollected >= maxEmails) break + for (const mailboxPath of mailboxes) { + if (totalEmailsCollected >= maxEmails) break - try { - await client.mailboxOpen(mailboxPath) - - let searchCriteria: Record = { unseen: true } - if (config.searchCriteria) { - if (typeof config.searchCriteria === 'object') { - searchCriteria = config.searchCriteria as unknown as Record - } else if (typeof config.searchCriteria === 'string') { - try { - searchCriteria = JSON.parse(config.searchCriteria) - } catch { - logger.warn(`[${requestId}] Invalid search criteria JSON, using default`) - } - } - } + try { + const mailbox = await client.mailboxOpen(mailboxPath) - const lastUidForMailbox = latestUidByMailbox[mailboxPath] || config.lastProcessedUid + const currentUidValidity = mailbox.uidValidity.toString() + const storedUidValidity = uidValidityByMailbox[mailboxPath] - if (lastUidForMailbox) { - searchCriteria = { ...searchCriteria, uid: `${lastUidForMailbox + 1}:*` } - } + if (storedUidValidity && storedUidValidity !== currentUidValidity) { + logger.warn( + `[${requestId}] UIDVALIDITY changed for ${mailboxPath} (${storedUidValidity} -> ${currentUidValidity}), discarding stored UID` + ) + delete latestUidByMailbox[mailboxPath] + } + uidValidityByMailbox[mailboxPath] = currentUidValidity - if (config.lastCheckedTimestamp) { - const lastChecked = new Date(config.lastCheckedTimestamp) - const bufferTime = new Date(lastChecked.getTime() - 60000) - searchCriteria = { ...searchCriteria, since: bufferTime } - } else { - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) - searchCriteria = { ...searchCriteria, since: oneDayAgo } + let searchCriteria: Record = { unseen: true } + if (config.searchCriteria) { + if (typeof config.searchCriteria === 'object') { + searchCriteria = config.searchCriteria as unknown as Record + } else if (typeof config.searchCriteria === 'string') { + try { + searchCriteria = JSON.parse(config.searchCriteria) + } catch { + logger.warn(`[${requestId}] Invalid search criteria JSON, using default`) + } } + } - let messageUids: number[] = [] - try { - const searchResult = await client.search(searchCriteria, { uid: true }) - messageUids = searchResult === false ? [] : searchResult - } catch { - continue - } + const lastUidForMailbox = latestUidByMailbox[mailboxPath] - if (messageUids.length === 0) continue + if (lastUidForMailbox) { + searchCriteria = { ...searchCriteria, uid: `${lastUidForMailbox + 1}:*` } + } - messageUids.sort((a, b) => a - b) - const remainingSlots = maxEmails - totalEmailsCollected - const uidsToProcess = messageUids.slice(0, remainingSlots) + if (config.lastCheckedTimestamp) { + const lastChecked = new Date(config.lastCheckedTimestamp) + const bufferTime = new Date(lastChecked.getTime() - 60000) + searchCriteria = { ...searchCriteria, since: bufferTime } + } else { + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) + searchCriteria = { ...searchCriteria, since: oneDayAgo } + } - if (uidsToProcess.length > 0) { - latestUidByMailbox[mailboxPath] = Math.max( - ...uidsToProcess, - latestUidByMailbox[mailboxPath] || 0 - ) - } + let messageUids: number[] = [] + try { + const searchResult = await client.search(searchCriteria, { uid: true }) + messageUids = searchResult === false ? [] : searchResult + } catch { + continue + } - for await (const msg of client.fetch( - uidsToProcess, - { uid: true, envelope: true, bodyStructure: true, source: true }, - { uid: true } - )) { - emails.push({ - uid: msg.uid, - mailboxPath, - envelope: msg.envelope, - bodyStructure: msg.bodyStructure, - source: msg.source, - }) - totalEmailsCollected++ + if (messageUids.length === 0) continue + + messageUids.sort((a, b) => a - b) + const remainingSlots = maxEmails - totalEmailsCollected + const uidsToProcess = messageUids.slice(0, remainingSlots) + + for await (const msg of client.fetch( + uidsToProcess, + { uid: true, envelope: true, bodyStructure: true, source: true }, + { uid: true } + )) { + emails.push({ + uid: msg.uid, + mailboxPath, + envelope: msg.envelope, + bodyStructure: msg.bodyStructure, + source: msg.source, + }) + if (msg.uid > (latestUidByMailbox[mailboxPath] || 0)) { + latestUidByMailbox[mailboxPath] = msg.uid } - } catch (mailboxError) { - logger.warn(`[${requestId}] Error processing mailbox ${mailboxPath}:`, mailboxError) + totalEmailsCollected++ } + } catch (mailboxError) { + logger.warn(`[${requestId}] Error processing mailbox ${mailboxPath}:`, mailboxError) } - - await client.logout() - return { emails, latestUidByMailbox } - } catch (error) { - try { - await client.logout() - } catch { - // Ignore logout errors - } - throw error } + + return { emails, latestUidByMailbox, uidValidityByMailbox } } function getMailboxesToCheck(config: ImapWebhookConfig): string[] { @@ -331,9 +380,7 @@ function extractTextFromSource(source: Buffer): { text: string; html: string } { if (lowerPart.includes('base64')) { try { text = Buffer.from(text.replace(/\s/g, ''), 'base64').toString('utf-8') - } catch { - // Keep as-is if base64 decode fails - } + } catch {} } } } else if (lowerPart.includes('content-type: text/html')) { @@ -348,9 +395,7 @@ function extractTextFromSource(source: Buffer): { text: string; html: string } { if (lowerPart.includes('base64')) { try { html = Buffer.from(html.replace(/\s/g, ''), 'base64').toString('utf-8') - } catch { - // Keep as-is if base64 decode fails - } + } catch {} } } } @@ -405,9 +450,7 @@ function extractAttachmentsFromSource( mimeType, size: buffer.length, }) - } catch { - // Skip if decode fails - } + } catch {} } } } @@ -437,34 +480,17 @@ async function processEmails( webhookData: PollWebhookContext['webhookData'], workflowData: PollWebhookContext['workflowData'], config: ImapWebhookConfig, + client: ImapFlow, requestId: string, - resolvedIP: string, logger: ReturnType ) { let processedCount = 0 let failedCount = 0 - const client = new ImapFlow({ - host: resolvedIP, - servername: config.host, - port: config.port || 993, - secure: config.secure ?? true, - auth: { - user: config.username, - pass: config.password, - }, - tls: { rejectUnauthorized: true }, - logger: false, - }) - let currentOpenMailbox: string | null = null const lockState: { lock: MailboxLockObject | null } = { lock: null } try { - if (config.markAsRead) { - await client.connect() - } - for (const email of emails) { try { await pollingIdempotency.executeWithIdempotency( @@ -541,7 +567,7 @@ async function processEmails( lockState.lock = await client.getMailboxLock(email.mailboxPath) currentOpenMailbox = email.mailboxPath } - await client.messageFlagsAdd({ uid: email.uid }, ['\\Seen'], { uid: true }) + await client.messageFlagsAdd(email.uid, ['\\Seen'], { uid: true }) } catch (flagError) { logger.warn( `[${requestId}] Failed to mark message ${email.uid} as read:`, @@ -565,15 +591,10 @@ async function processEmails( } } } finally { - if (config.markAsRead) { + if (lockState.lock) { try { - if (lockState.lock) { - lockState.lock.release() - } - await client.logout() - } catch { - // Ignore logout errors - } + lockState.lock.release() + } catch {} } } diff --git a/apps/sim/lib/webhooks/polling/outlook.ts b/apps/sim/lib/webhooks/polling/outlook.ts index e6874940c61..faef69776e5 100644 --- a/apps/sim/lib/webhooks/polling/outlook.ts +++ b/apps/sim/lib/webhooks/polling/outlook.ts @@ -1,5 +1,6 @@ import { htmlToText } from 'html-to-text' import { pollingIdempotency } from '@/lib/core/idempotency/service' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' import { markWebhookFailed, @@ -166,6 +167,12 @@ export const outlookPollingHandler: PollingProviderHandler = { }, } +/** Hard cap on total emails fetched per poll to prevent unbounded pagination loops. */ +const OUTLOOK_HARD_MAX_EMAILS = 200 + +/** Number of items to request per Graph API page. Decoupled from the total cap so pagination actually runs. */ +const OUTLOOK_PAGE_SIZE = 50 + async function fetchNewOutlookEmails( accessToken: string, config: OutlookWebhookConfig, @@ -181,53 +188,77 @@ async function fetchNewOutlookEmails( 'id,conversationId,subject,bodyPreview,body,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,hasAttachments,isRead,parentFolderId' ) params.append('$orderby', 'receivedDateTime desc') - params.append('$top', (config.maxEmailsPerPoll || 25).toString()) + const maxEmails = Math.min(config.maxEmailsPerPoll || 25, OUTLOOK_HARD_MAX_EMAILS) + params.append('$top', OUTLOOK_PAGE_SIZE.toString()) if (config.lastCheckedTimestamp) { const lastChecked = new Date(config.lastCheckedTimestamp) const bufferTime = new Date(lastChecked.getTime() - 60000) params.append('$filter', `receivedDateTime gt ${bufferTime.toISOString()}`) } + const allEmails: OutlookEmail[] = [] + let nextUrl: string | undefined = `${apiUrl}?${params.toString()}` + logger.info(`[${requestId}] Fetching emails from: ${nextUrl}`) - const fullUrl = `${apiUrl}?${params.toString()}` - logger.info(`[${requestId}] Fetching emails from: ${fullUrl}`) + while (nextUrl && allEmails.length < maxEmails) { + const response = await fetchWithRetry(nextUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) - const response = await fetch(fullUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: { message: 'Unknown error' } })) + logger.error(`[${requestId}] Microsoft Graph API error:`, { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + throw new Error( + `Microsoft Graph API error: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` + ) + } - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Microsoft Graph API error:`, { - status: response.status, - statusText: response.statusText, - error: errorData, - }) - throw new Error( - `Microsoft Graph API error: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` - ) + const data = await response.json() + const pageEmails: OutlookEmail[] = data.value || [] + const remaining = maxEmails - allEmails.length + allEmails.push(...pageEmails.slice(0, remaining)) + + nextUrl = + allEmails.length < maxEmails ? (data['@odata.nextLink'] as string | undefined) : undefined + + if (pageEmails.length === 0) break } - const data = await response.json() - const emails = data.value || [] + logger.info(`[${requestId}] Fetched ${allEmails.length} emails total`) + + const emails = allEmails let resolvedFolderIds: Map | undefined + let skipFolderFilter = false if (config.folderIds && config.folderIds.length > 0) { - const hasWellKnownFolders = config.folderIds.some(isWellKnownFolderName) - if (hasWellKnownFolders) { + const wellKnownFolders = config.folderIds.filter(isWellKnownFolderName) + if (wellKnownFolders.length > 0) { resolvedFolderIds = await resolveWellKnownFolderIds( accessToken, config.folderIds, requestId, logger ) + if (resolvedFolderIds.size < wellKnownFolders.length) { + logger.warn( + `[${requestId}] Could not resolve all well-known folders (${resolvedFolderIds.size}/${wellKnownFolders.length}) — skipping folder filter to avoid incorrect results` + ) + skipFolderFilter = true + } } } - const filteredEmails = filterEmailsByFolder(emails, config, resolvedFolderIds) + const filteredEmails = skipFolderFilter + ? emails + : filterEmailsByFolder(emails, config, resolvedFolderIds) logger.info( `[${requestId}] Fetched ${emails.length} emails, ${filteredEmails.length} after filtering` @@ -262,12 +293,14 @@ async function resolveWellKnownFolderId( logger: ReturnType ): Promise { try { - const response = await fetch(`https://graph.microsoft.com/v1.0/me/mailFolders/${folderName}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) + const response = await fetchWithRetry( + `https://graph.microsoft.com/v1.0/me/mailFolders/${folderName}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) if (!response.ok) { logger.warn( @@ -455,12 +488,11 @@ async function downloadOutlookAttachments( const attachments: OutlookAttachment[] = [] try { - const response = await fetch( + const response = await fetchWithRetry( `https://graph.microsoft.com/v1.0/me/messages/${messageId}/attachments`, { headers: { Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', }, } ) @@ -511,14 +543,17 @@ async function markOutlookEmailAsRead( logger: ReturnType ) { try { - const response = await fetch(`https://graph.microsoft.com/v1.0/me/messages/${messageId}`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ isRead: true }), - }) + const response = await fetchWithRetry( + `https://graph.microsoft.com/v1.0/me/messages/${messageId}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ isRead: true }), + } + ) if (!response.ok) { logger.error( diff --git a/apps/sim/lib/webhooks/polling/rss.ts b/apps/sim/lib/webhooks/polling/rss.ts index 31044fc8924..5f52ad8afdc 100644 --- a/apps/sim/lib/webhooks/polling/rss.ts +++ b/apps/sim/lib/webhooks/polling/rss.ts @@ -12,7 +12,7 @@ import { } from '@/lib/webhooks/polling/utils' import { processPolledWebhookEvent } from '@/lib/webhooks/processor' -const MAX_GUIDS_TO_TRACK = 100 +const MAX_GUIDS_TO_TRACK = 500 interface RssWebhookConfig { feedUrl: string @@ -87,10 +87,15 @@ export const rssPollingHandler: PollingProviderHandler = { } const now = new Date() - const { feed, items: newItems } = await fetchNewRssItems(config, requestId, logger) + const { + feed, + items: newItems, + etag, + lastModified, + } = await fetchNewRssItems(config, requestId, logger) if (!newItems.length) { - await updateRssState(webhookId, now.toISOString(), [], config, logger) + await updateRssState(webhookId, now.toISOString(), [], config, logger, etag, lastModified) await markWebhookSuccess(webhookId, logger) logger.info(`[${requestId}] No new items found for webhook ${webhookId}`) return 'success' @@ -108,10 +113,23 @@ export const rssPollingHandler: PollingProviderHandler = { ) const newGuids = newItems - .map((item) => item.guid || item.link || '') + .map( + (item) => + item.guid || + item.link || + (item.title && item.pubDate ? `${item.title}-${item.pubDate}` : '') + ) .filter((guid) => guid.length > 0) - await updateRssState(webhookId, now.toISOString(), newGuids, config, logger) + await updateRssState( + webhookId, + now.toISOString(), + newGuids, + config, + logger, + etag, + lastModified + ) if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) @@ -139,7 +157,9 @@ async function updateRssState( timestamp: string, newGuids: string[], config: RssWebhookConfig, - logger: ReturnType + logger: ReturnType, + etag?: string, + lastModified?: string ) { const existingGuids = config.lastSeenGuids || [] const allGuids = [...newGuids, ...existingGuids].slice(0, MAX_GUIDS_TO_TRACK) @@ -149,6 +169,8 @@ async function updateRssState( { lastCheckedTimestamp: timestamp, lastSeenGuids: allGuids, + ...(etag !== undefined ? { etag } : {}), + ...(lastModified !== undefined ? { lastModified } : {}), }, logger ) @@ -158,7 +180,7 @@ async function fetchNewRssItems( config: RssWebhookConfig, requestId: string, logger: ReturnType -): Promise<{ feed: RssFeed; items: RssItem[] }> { +): Promise<{ feed: RssFeed; items: RssItem[]; etag?: string; lastModified?: string }> { try { const urlValidation = await validateUrlWithDNS(config.feedUrl, 'feedUrl') if (!urlValidation.isValid) { @@ -166,24 +188,45 @@ async function fetchNewRssItems( throw new Error(`Invalid RSS feed URL: ${urlValidation.error}`) } + const headers: Record = { + 'User-Agent': 'Sim/1.0 RSS Poller', + Accept: 'application/rss+xml, application/xml, text/xml, */*', + } + if (config.etag) { + headers['If-None-Match'] = config.etag + } + if (config.lastModified) { + headers['If-Modified-Since'] = config.lastModified + } + const response = await secureFetchWithPinnedIP(config.feedUrl, urlValidation.resolvedIP!, { - headers: { - 'User-Agent': 'Sim/1.0 RSS Poller', - Accept: 'application/rss+xml, application/xml, text/xml, */*', - }, + headers, timeout: 30000, }) + if (response.status === 304) { + logger.info(`[${requestId}] RSS feed not modified (304) for ${config.feedUrl}`) + return { + feed: { items: [] } as RssFeed, + items: [], + etag: response.headers.get('etag') ?? config.etag, + lastModified: response.headers.get('last-modified') ?? config.lastModified, + } + } + if (!response.ok) { await response.text().catch(() => {}) throw new Error(`Failed to fetch RSS feed: ${response.status} ${response.statusText}`) } + const newEtag = response.headers.get('etag') ?? undefined + const newLastModified = response.headers.get('last-modified') ?? undefined + const xmlContent = await response.text() const feed = await parser.parseString(xmlContent) if (!feed.items || !feed.items.length) { - return { feed: feed as RssFeed, items: [] } + return { feed: feed as RssFeed, items: [], etag: newEtag, lastModified: newLastModified } } const lastCheckedTime = config.lastCheckedTimestamp @@ -192,7 +235,10 @@ async function fetchNewRssItems( const lastSeenGuids = new Set(config.lastSeenGuids || []) const newItems = feed.items.filter((item) => { - const itemGuid = item.guid || item.link || '' + const itemGuid = + item.guid || + item.link || + (item.title && item.pubDate ? `${item.title}-${item.pubDate}` : '') if (itemGuid && lastSeenGuids.has(itemGuid)) { return false @@ -220,7 +266,12 @@ async function fetchNewRssItems( `[${requestId}] Found ${newItems.length} new items (processing ${limitedItems.length})` ) - return { feed: feed as RssFeed, items: limitedItems as RssItem[] } + return { + feed: feed as RssFeed, + items: limitedItems as RssItem[], + etag: newEtag, + lastModified: newLastModified, + } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Error fetching RSS feed:`, errorMessage) @@ -241,7 +292,17 @@ async function processRssItems( for (const item of items) { try { - const itemGuid = item.guid || item.link || `${item.title}-${item.pubDate}` + const itemGuid = + item.guid || + item.link || + (item.title && item.pubDate ? `${item.title}-${item.pubDate}` : '') + + if (!itemGuid) { + logger.warn( + `[${requestId}] Skipping RSS item with no identifiable GUID for webhook ${webhookData.id}` + ) + continue + } await pollingIdempotency.executeWithIdempotency( 'rss', diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 789546a755b..332add6598f 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -28,6 +28,7 @@ import { outlookHandler } from '@/lib/webhooks/providers/outlook' import { resendHandler } from '@/lib/webhooks/providers/resend' import { rssHandler } from '@/lib/webhooks/providers/rss' import { salesforceHandler } from '@/lib/webhooks/providers/salesforce' +import { servicenowHandler } from '@/lib/webhooks/providers/servicenow' import { slackHandler } from '@/lib/webhooks/providers/slack' import { stripeHandler } from '@/lib/webhooks/providers/stripe' import { telegramHandler } from '@/lib/webhooks/providers/telegram' @@ -72,6 +73,7 @@ const PROVIDER_HANDLERS: Record = { outlook: outlookHandler, rss: rssHandler, salesforce: salesforceHandler, + servicenow: servicenowHandler, slack: slackHandler, stripe: stripeHandler, telegram: telegramHandler, diff --git a/apps/sim/lib/webhooks/providers/servicenow.ts b/apps/sim/lib/webhooks/providers/servicenow.ts new file mode 100644 index 00000000000..8118bd72ed8 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/servicenow.ts @@ -0,0 +1,57 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventMatchContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:ServiceNow') + +function asRecord(body: unknown): Record { + return body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : {} +} + +export const servicenowHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret?.trim()) { + logger.warn(`[${requestId}] ServiceNow webhook missing webhookSecret — rejecting`) + return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 }) + } + + if ( + !verifyTokenAuth(request, secret.trim(), 'x-sim-webhook-secret') && + !verifyTokenAuth(request, secret.trim()) + ) { + logger.warn(`[${requestId}] ServiceNow webhook secret verification failed`) + return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 }) + } + + return null + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId) { + return true + } + + const { isServiceNowEventMatch } = await import('@/triggers/servicenow/utils') + const configuredTableName = providerConfig.tableName as string | undefined + const obj = asRecord(body) + + if (!isServiceNowEventMatch(triggerId, obj, configuredTableName)) { + logger.debug( + `[${requestId}] ServiceNow event mismatch for trigger ${triggerId}. Skipping execution.`, + { webhookId: webhook.id, workflowId: workflow.id, triggerId } + ) + return false + } + + return true + }, +} diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index f739f3f20d1..ee0e6e170ad 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -14,7 +14,10 @@ import { normalizeVariables, sanitizeVariable, } from './normalize' -import { formatValueForDisplay, resolveValueForDisplay } from './resolve-values' +import { formatValueForDisplay, resolveFieldLabel, resolveValueForDisplay } from './resolve-values' + +const MAX_CHANGES_PER_BLOCK = 6 +const MAX_EDGE_DETAILS = 3 const logger = createLogger('WorkflowComparison') @@ -45,10 +48,22 @@ export interface WorkflowDiffSummary { addedBlocks: Array<{ id: string; type: string; name?: string }> removedBlocks: Array<{ id: string; type: string; name?: string }> modifiedBlocks: Array<{ id: string; type: string; name?: string; changes: FieldChange[] }> - edgeChanges: { added: number; removed: number } + edgeChanges: { + added: number + removed: number + addedDetails: Array<{ sourceName: string; targetName: string }> + removedDetails: Array<{ sourceName: string; targetName: string }> + } loopChanges: { added: number; removed: number; modified: number } parallelChanges: { added: number; removed: number; modified: number } - variableChanges: { added: number; removed: number; modified: number } + variableChanges: { + added: number + removed: number + modified: number + addedNames: string[] + removedNames: string[] + modifiedNames: string[] + } hasChanges: boolean } @@ -63,10 +78,17 @@ export function generateWorkflowDiffSummary( addedBlocks: [], removedBlocks: [], modifiedBlocks: [], - edgeChanges: { added: 0, removed: 0 }, + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, loopChanges: { added: 0, removed: 0, modified: 0 }, parallelChanges: { added: 0, removed: 0, modified: 0 }, - variableChanges: { added: 0, removed: 0, modified: 0 }, + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, hasChanges: false, } @@ -79,10 +101,28 @@ export function generateWorkflowDiffSummary( name: block.name, }) } - result.edgeChanges.added = (currentState.edges || []).length + + const edges = currentState.edges || [] + result.edgeChanges.added = edges.length + for (const edge of edges) { + const sourceBlock = currentBlocks[edge.source] + const targetBlock = currentBlocks[edge.target] + result.edgeChanges.addedDetails.push({ + sourceName: sourceBlock?.name || sourceBlock?.type || edge.source, + targetName: targetBlock?.name || targetBlock?.type || edge.target, + }) + } + result.loopChanges.added = Object.keys(currentState.loops || {}).length result.parallelChanges.added = Object.keys(currentState.parallels || {}).length - result.variableChanges.added = Object.keys(currentState.variables || {}).length + + const variables = currentState.variables || {} + const varEntries = Object.entries(variables) + result.variableChanges.added = varEntries.length + for (const [id, variable] of varEntries) { + result.variableChanges.addedNames.push((variable as { name?: string }).name || id) + } + result.hasChanges = true return result } @@ -121,7 +161,6 @@ export function generateWorkflowDiffSummary( const previousBlock = previousBlocks[id] const changes: FieldChange[] = [] - // Use shared helpers for block field extraction (single source of truth) const { blockRest: currentRest, normalizedData: currentDataRest, @@ -156,8 +195,6 @@ export function generateWorkflowDiffSummary( newValue: currentBlock.enabled, }) } - // Check other block properties (boolean fields) - // Use !! to normalize: null/undefined/false are all equivalent (falsy) const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const for (const field of blockFields) { if (!!currentBlock[field] !== !!previousBlock[field]) { @@ -169,15 +206,27 @@ export function generateWorkflowDiffSummary( } } if (normalizedStringify(currentDataRest) !== normalizedStringify(previousDataRest)) { - changes.push({ field: 'data', oldValue: previousDataRest, newValue: currentDataRest }) + const allDataKeys = new Set([ + ...Object.keys(currentDataRest), + ...Object.keys(previousDataRest), + ]) + for (const key of allDataKeys) { + if ( + normalizedStringify(currentDataRest[key]) !== normalizedStringify(previousDataRest[key]) + ) { + changes.push({ + field: `data.${key}`, + oldValue: previousDataRest[key] ?? null, + newValue: currentDataRest[key] ?? null, + }) + } + } } } - // Normalize trigger config values for both states before comparison const normalizedCurrentSubs = normalizeTriggerConfigValues(currentSubBlocks) const normalizedPreviousSubs = normalizeTriggerConfigValues(previousSubBlocks) - // Compare subBlocks using shared helper for filtering (single source of truth) const allSubBlockIds = filterSubBlockIds([ ...new Set([...Object.keys(normalizedCurrentSubs), ...Object.keys(normalizedPreviousSubs)]), ]) @@ -195,11 +244,9 @@ export function generateWorkflowDiffSummary( continue } - // Use shared helper for subBlock value normalization (single source of truth) const currentValue = normalizeSubBlockValue(subId, currentSub.value) const previousValue = normalizeSubBlockValue(subId, previousSub.value) - // For string values, compare directly to catch even small text changes if (typeof currentValue === 'string' && typeof previousValue === 'string') { if (currentValue !== previousValue) { changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value }) @@ -212,7 +259,6 @@ export function generateWorkflowDiffSummary( } } - // Use shared helper for subBlock REST extraction (single source of truth) const currentSubRest = extractSubBlockRest(currentSub) const previousSubRest = extractSubBlockRest(previousSub) @@ -240,11 +286,30 @@ export function generateWorkflowDiffSummary( const currentEdgeSet = new Set(currentEdges.map(normalizedStringify)) const previousEdgeSet = new Set(previousEdges.map(normalizedStringify)) - for (const edge of currentEdgeSet) { - if (!previousEdgeSet.has(edge)) result.edgeChanges.added++ + const resolveBlockName = (blockId: string): string => { + const block = currentBlocks[blockId] || previousBlocks[blockId] + return block?.name || block?.type || blockId + } + + for (const edgeStr of currentEdgeSet) { + if (!previousEdgeSet.has(edgeStr)) { + result.edgeChanges.added++ + const edge = JSON.parse(edgeStr) as { source: string; target: string } + result.edgeChanges.addedDetails.push({ + sourceName: resolveBlockName(edge.source), + targetName: resolveBlockName(edge.target), + }) + } } - for (const edge of previousEdgeSet) { - if (!currentEdgeSet.has(edge)) result.edgeChanges.removed++ + for (const edgeStr of previousEdgeSet) { + if (!currentEdgeSet.has(edgeStr)) { + result.edgeChanges.removed++ + const edge = JSON.parse(edgeStr) as { source: string; target: string } + result.edgeChanges.removedDetails.push({ + sourceName: resolveBlockName(edge.source), + targetName: resolveBlockName(edge.target), + }) + } } const currentLoops = currentState.loops || {} @@ -296,8 +361,18 @@ export function generateWorkflowDiffSummary( const currentVarIds = Object.keys(currentVars) const previousVarIds = Object.keys(previousVars) - result.variableChanges.added = currentVarIds.filter((id) => !previousVarIds.includes(id)).length - result.variableChanges.removed = previousVarIds.filter((id) => !currentVarIds.includes(id)).length + for (const id of currentVarIds) { + if (!previousVarIds.includes(id)) { + result.variableChanges.added++ + result.variableChanges.addedNames.push(currentVars[id].name || id) + } + } + for (const id of previousVarIds) { + if (!currentVarIds.includes(id)) { + result.variableChanges.removed++ + result.variableChanges.removedNames.push(previousVars[id].name || id) + } + } for (const id of currentVarIds) { if (!previousVarIds.includes(id)) continue @@ -305,6 +380,7 @@ export function generateWorkflowDiffSummary( const previousVar = normalizeValue(sanitizeVariable(previousVars[id])) if (normalizedStringify(currentVar) !== normalizedStringify(previousVar)) { result.variableChanges.modified++ + result.variableChanges.modifiedNames.push(currentVars[id].name || id) } } @@ -349,56 +425,24 @@ export function formatDiffSummaryForDescription(summary: WorkflowDiffSummary): s for (const block of summary.modifiedBlocks) { const name = block.name || block.type - for (const change of block.changes.slice(0, 3)) { + const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties')) + for (const change of meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK)) { + const fieldLabel = resolveFieldLabel(block.type, change.field) const oldStr = formatValueForDisplay(change.oldValue) const newStr = formatValueForDisplay(change.newValue) - changes.push(`Modified ${name}: ${change.field} changed from "${oldStr}" to "${newStr}"`) + changes.push(`Modified ${name}: ${fieldLabel} changed from "${oldStr}" to "${newStr}"`) } - if (block.changes.length > 3) { - changes.push(` ...and ${block.changes.length - 3} more changes in ${name}`) + if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) { + changes.push( + ` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}` + ) } } - if (summary.edgeChanges.added > 0) { - changes.push(`Added ${summary.edgeChanges.added} connection(s)`) - } - if (summary.edgeChanges.removed > 0) { - changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`) - } - - if (summary.loopChanges.added > 0) { - changes.push(`Added ${summary.loopChanges.added} loop(s)`) - } - if (summary.loopChanges.removed > 0) { - changes.push(`Removed ${summary.loopChanges.removed} loop(s)`) - } - if (summary.loopChanges.modified > 0) { - changes.push(`Modified ${summary.loopChanges.modified} loop(s)`) - } - - if (summary.parallelChanges.added > 0) { - changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`) - } - if (summary.parallelChanges.removed > 0) { - changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`) - } - if (summary.parallelChanges.modified > 0) { - changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`) - } - - const varChanges: string[] = [] - if (summary.variableChanges.added > 0) { - varChanges.push(`${summary.variableChanges.added} added`) - } - if (summary.variableChanges.removed > 0) { - varChanges.push(`${summary.variableChanges.removed} removed`) - } - if (summary.variableChanges.modified > 0) { - varChanges.push(`${summary.variableChanges.modified} modified`) - } - if (varChanges.length > 0) { - changes.push(`Variables: ${varChanges.join(', ')}`) - } + formatEdgeChanges(summary, changes) + formatCountChanges(summary.loopChanges, 'loop', changes) + formatCountChanges(summary.parallelChanges, 'parallel group', changes) + formatVariableChanges(summary, changes) return changes.join('\n') } @@ -437,8 +481,9 @@ export async function formatDiffSummaryForDescriptionAsync( const modifiedBlockPromises = summary.modifiedBlocks.map(async (block) => { const name = block.name || block.type const blockChanges: string[] = [] + const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties')) - const changesToProcess = block.changes.slice(0, 3) + const changesToProcess = meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK) const resolvedChanges = await Promise.all( changesToProcess.map(async (change) => { const context = { @@ -455,7 +500,7 @@ export async function formatDiffSummaryForDescriptionAsync( ]) return { - field: change.field, + field: resolveFieldLabel(block.type, change.field), oldLabel: oldResolved.displayLabel, newLabel: newResolved.displayLabel, } @@ -468,8 +513,10 @@ export async function formatDiffSummaryForDescriptionAsync( ) } - if (block.changes.length > 3) { - blockChanges.push(` ...and ${block.changes.length - 3} more changes in ${name}`) + if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) { + blockChanges.push( + ` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}` + ) } return blockChanges @@ -480,52 +527,95 @@ export async function formatDiffSummaryForDescriptionAsync( changes.push(...blockChanges) } - if (summary.edgeChanges.added > 0) { - changes.push(`Added ${summary.edgeChanges.added} connection(s)`) - } - if (summary.edgeChanges.removed > 0) { - changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`) - } + formatEdgeChanges(summary, changes) + formatCountChanges(summary.loopChanges, 'loop', changes) + formatCountChanges(summary.parallelChanges, 'parallel group', changes) + formatVariableChanges(summary, changes) - if (summary.loopChanges.added > 0) { - changes.push(`Added ${summary.loopChanges.added} loop(s)`) - } - if (summary.loopChanges.removed > 0) { - changes.push(`Removed ${summary.loopChanges.removed} loop(s)`) - } - if (summary.loopChanges.modified > 0) { - changes.push(`Modified ${summary.loopChanges.modified} loop(s)`) - } + logger.info('Generated async diff description', { + workflowId, + changeCount: changes.length, + modifiedBlocks: summary.modifiedBlocks.length, + }) + + return changes.join('\n') +} - if (summary.parallelChanges.added > 0) { - changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`) +function formatEdgeDetailList( + edges: Array<{ sourceName: string; targetName: string }>, + total: number, + verb: string, + changes: string[] +): void { + if (edges.length === 0) { + changes.push(`${verb} ${total} connection(s)`) + return } - if (summary.parallelChanges.removed > 0) { - changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`) + for (const edge of edges.slice(0, MAX_EDGE_DETAILS)) { + changes.push(`${verb} connection: ${edge.sourceName} -> ${edge.targetName}`) } - if (summary.parallelChanges.modified > 0) { - changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`) + if (total > MAX_EDGE_DETAILS) { + changes.push(` ...and ${total - MAX_EDGE_DETAILS} more ${verb.toLowerCase()} connection(s)`) } +} - const varChanges: string[] = [] - if (summary.variableChanges.added > 0) { - varChanges.push(`${summary.variableChanges.added} added`) - } - if (summary.variableChanges.removed > 0) { - varChanges.push(`${summary.variableChanges.removed} removed`) - } - if (summary.variableChanges.modified > 0) { - varChanges.push(`${summary.variableChanges.modified} modified`) +function formatEdgeChanges(summary: WorkflowDiffSummary, changes: string[]): void { + if (summary.edgeChanges.added > 0) { + formatEdgeDetailList( + summary.edgeChanges.addedDetails ?? [], + summary.edgeChanges.added, + 'Added', + changes + ) } - if (varChanges.length > 0) { - changes.push(`Variables: ${varChanges.join(', ')}`) + if (summary.edgeChanges.removed > 0) { + formatEdgeDetailList( + summary.edgeChanges.removedDetails ?? [], + summary.edgeChanges.removed, + 'Removed', + changes + ) } +} - logger.info('Generated async diff description', { - workflowId, - changeCount: changes.length, - modifiedBlocks: summary.modifiedBlocks.length, - }) +function formatCountChanges( + counts: { added: number; removed: number; modified: number }, + label: string, + changes: string[] +): void { + if (counts.added > 0) changes.push(`Added ${counts.added} ${label}(s)`) + if (counts.removed > 0) changes.push(`Removed ${counts.removed} ${label}(s)`) + if (counts.modified > 0) changes.push(`Modified ${counts.modified} ${label}(s)`) +} - return changes.join('\n') +function formatVariableChanges(summary: WorkflowDiffSummary, changes: string[]): void { + const categories = [ + { + count: summary.variableChanges.added, + names: summary.variableChanges.addedNames ?? [], + verb: 'added', + }, + { + count: summary.variableChanges.removed, + names: summary.variableChanges.removedNames ?? [], + verb: 'removed', + }, + { + count: summary.variableChanges.modified, + names: summary.variableChanges.modifiedNames ?? [], + verb: 'modified', + }, + ] as const + + const varParts: string[] = [] + for (const { count, names, verb } of categories) { + if (count > 0) { + varParts.push( + names.length > 0 ? `${verb} ${names.map((n) => `"${n}"`).join(', ')}` : `${count} ${verb}` + ) + } + } + if (varParts.length > 0) { + changes.push(`Variables: ${varParts.join(', ')}`) + } } diff --git a/apps/sim/lib/workflows/comparison/format-description.test.ts b/apps/sim/lib/workflows/comparison/format-description.test.ts new file mode 100644 index 00000000000..f186a9d5a4f --- /dev/null +++ b/apps/sim/lib/workflows/comparison/format-description.test.ts @@ -0,0 +1,864 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/lib/workflows/subblocks/visibility', () => ({ + isNonEmptyValue: (v: unknown) => v !== null && v !== undefined && v !== '', +})) + +vi.mock('@/triggers/constants', () => ({ + SYSTEM_SUBBLOCK_IDS: [], + TRIGGER_RUNTIME_SUBBLOCK_IDS: [], +})) + +vi.mock('@/blocks/types', () => ({ + SELECTOR_TYPES_HYDRATION_REQUIRED: [], +})) + +vi.mock('@/executor/constants', () => ({ + CREDENTIAL_SET: { PREFIX: 'cred_set_' }, + isUuid: (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v), +})) + +vi.mock('@/blocks/registry', () => ({ + getBlock: mockGetBlock, + getAllBlocks: () => ({}), + getAllBlockTypes: () => [], + registry: {}, +})) + +vi.mock('@/lib/workflows/subblocks/context', () => ({ + buildSelectorContextFromBlock: vi.fn(() => ({})), +})) + +vi.mock('@/hooks/queries/credential-sets', () => ({ + fetchCredentialSetById: vi.fn(), +})) + +vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ + fetchOAuthCredentialDetail: vi.fn(() => []), +})) + +vi.mock('@/hooks/selectors/registry', () => ({ + getSelectorDefinition: vi.fn(() => ({ fetchList: vi.fn(() => []) })), +})) + +vi.mock('@/hooks/selectors/resolution', () => ({ + resolveSelectorForSubBlock: vi.fn(), +})) + +import { WorkflowBuilder } from '@sim/testing' +import type { WorkflowDiffSummary } from '@/lib/workflows/comparison/compare' +import { + formatDiffSummaryForDescription, + formatDiffSummaryForDescriptionAsync, + generateWorkflowDiffSummary, +} from '@/lib/workflows/comparison/compare' +import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values' + +function emptyDiffSummary(overrides: Partial = {}): WorkflowDiffSummary { + return { + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, + loopChanges: { added: 0, removed: 0, modified: 0 }, + parallelChanges: { added: 0, removed: 0, modified: 0 }, + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, + hasChanges: false, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('resolveFieldLabel', () => { + it('resolves subBlock id to its title', () => { + mockGetBlock.mockReturnValue({ + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + ], + }) + expect(resolveFieldLabel('agent', 'systemPrompt')).toBe('System Prompt') + expect(resolveFieldLabel('agent', 'model')).toBe('Model') + }) + + it('falls back to raw id when block not found', () => { + mockGetBlock.mockReturnValue(null) + expect(resolveFieldLabel('unknown_type', 'someField')).toBe('someField') + }) + + it('falls back to raw id when subBlock not found', () => { + mockGetBlock.mockReturnValue({ subBlocks: [{ id: 'other', title: 'Other' }] }) + expect(resolveFieldLabel('agent', 'missingField')).toBe('missingField') + }) + + it('converts data.* fields to Title Case', () => { + expect(resolveFieldLabel('agent', 'data.loopType')).toBe('Loop Type') + expect(resolveFieldLabel('agent', 'data.canonicalModes')).toBe('Canonical Modes') + expect(resolveFieldLabel('agent', 'data.isStarter')).toBe('Is Starter') + }) +}) + +describe('formatValueForDisplay', () => { + it('handles null/undefined', () => { + expect(formatValueForDisplay(null)).toBe('(none)') + expect(formatValueForDisplay(undefined)).toBe('(none)') + }) + + it('handles booleans', () => { + expect(formatValueForDisplay(true)).toBe('enabled') + expect(formatValueForDisplay(false)).toBe('disabled') + }) + + it('truncates long strings', () => { + const longStr = 'a'.repeat(60) + expect(formatValueForDisplay(longStr)).toBe(`${'a'.repeat(50)}...`) + }) + + it('handles empty string', () => { + expect(formatValueForDisplay('')).toBe('(empty)') + }) +}) + +describe('formatDiffSummaryForDescription', () => { + it('returns no-changes message for empty diff', () => { + const result = formatDiffSummaryForDescription(emptyDiffSummary()) + expect(result).toBe('No structural changes detected (configuration may have changed)') + }) + + it('uses human-readable field labels for modified blocks', () => { + mockGetBlock.mockReturnValue({ + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + ], + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'block-1', + type: 'agent', + name: 'My Agent', + changes: [ + { field: 'systemPrompt', oldValue: 'You are helpful', newValue: 'You are an expert' }, + { field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' }, + ], + }, + ], + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain( + 'Modified My Agent: System Prompt changed from "You are helpful" to "You are an expert"' + ) + expect(result).toContain( + 'Modified My Agent: Model changed from "gpt-4o" to "claude-sonnet-4-5"' + ) + expect(result).not.toContain('systemPrompt') + expect(result).not.toContain('model changed') + }) + + it('filters out .properties changes', () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'block-1', + type: 'agent', + name: 'Agent', + changes: [ + { field: 'systemPrompt', oldValue: 'old', newValue: 'new' }, + { + field: 'systemPrompt.properties', + oldValue: { some: 'meta' }, + newValue: { some: 'other' }, + }, + { field: 'model.properties', oldValue: {}, newValue: { x: 1 } }, + ], + }, + ], + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain('systemPrompt changed') + expect(result).not.toContain('.properties') + expect(result).not.toContain('model.properties') + }) + + it('respects MAX_CHANGES_PER_BLOCK limit of 6', () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const changes = Array.from({ length: 8 }, (_, i) => ({ + field: `field${i}`, + oldValue: `old${i}`, + newValue: `new${i}`, + })) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [{ id: 'b1', type: 'agent', name: 'Agent', changes }], + }) + + const result = formatDiffSummaryForDescription(summary) + const lines = result.split('\n') + const modifiedLines = lines.filter((l) => l.startsWith('Modified')) + expect(modifiedLines).toHaveLength(6) + expect(result).toContain('...and 2 more changes in Agent') + }) + + it('shows edge changes with block names', () => { + const summary = emptyDiffSummary({ + hasChanges: true, + edgeChanges: { + added: 2, + removed: 1, + addedDetails: [ + { sourceName: 'My Agent', targetName: 'Slack' }, + { sourceName: 'Router', targetName: 'Gmail' }, + ], + removedDetails: [{ sourceName: 'Function', targetName: 'Webhook' }], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain('Added connection: My Agent -> Slack') + expect(result).toContain('Added connection: Router -> Gmail') + expect(result).toContain('Removed connection: Function -> Webhook') + }) + + it('truncates edge details beyond MAX_EDGE_DETAILS', () => { + const summary = emptyDiffSummary({ + hasChanges: true, + edgeChanges: { + added: 5, + removed: 0, + addedDetails: [ + { sourceName: 'A', targetName: 'B' }, + { sourceName: 'C', targetName: 'D' }, + { sourceName: 'E', targetName: 'F' }, + { sourceName: 'G', targetName: 'H' }, + { sourceName: 'I', targetName: 'J' }, + ], + removedDetails: [], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + const connectionLines = result.split('\n').filter((l) => l.startsWith('Added connection')) + expect(connectionLines).toHaveLength(3) + expect(result).toContain('...and 2 more added connection(s)') + }) + + it('shows variable changes with names', () => { + const summary = emptyDiffSummary({ + hasChanges: true, + variableChanges: { + added: 2, + removed: 1, + modified: 1, + addedNames: ['counter', 'apiKey'], + removedNames: ['oldVar'], + modifiedNames: ['threshold'], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain( + 'Variables: added "counter", "apiKey", removed "oldVar", modified "threshold"' + ) + }) + + it('handles data.* fields with Title Case labels', () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'Agent', + changes: [ + { field: 'data.loopType', oldValue: 'for', newValue: 'forEach' }, + { field: 'data.isStarter', oldValue: true, newValue: false }, + ], + }, + ], + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain('Modified Agent: Loop Type changed from "for" to "forEach"') + expect(result).toContain('Modified Agent: Is Starter changed from "enabled" to "disabled"') + }) + + it('formats a realistic multi-block workflow change', () => { + mockGetBlock.mockImplementation((type: string) => { + if (type === 'agent') { + return { + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + { id: 'temperature', title: 'Temperature' }, + ], + } + } + if (type === 'slack') { + return { + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { id: 'slack_send_message', label: 'Send Message' }, + { id: 'slack_list_channels', label: 'List Channels' }, + ], + }, + { id: 'channel', title: 'Channel' }, + { id: 'credential', title: 'Slack Account' }, + ], + } + } + return null + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + addedBlocks: [{ id: 'b3', type: 'gmail', name: 'Gmail Notifications' }], + removedBlocks: [{ id: 'b4', type: 'function', name: 'Legacy Transform' }], + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'AI Assistant', + changes: [ + { field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' }, + { field: 'temperature', oldValue: '0.7', newValue: '0.3' }, + ], + }, + { + id: 'b2', + type: 'slack', + name: 'Slack Alert', + changes: [{ field: 'channel', oldValue: '#general', newValue: '#alerts' }], + }, + ], + edgeChanges: { + added: 1, + removed: 0, + addedDetails: [{ sourceName: 'AI Assistant', targetName: 'Gmail Notifications' }], + removedDetails: [], + }, + variableChanges: { + added: 1, + removed: 0, + modified: 0, + addedNames: ['errorCount'], + removedNames: [], + modifiedNames: [], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Gmail Notifications (gmail)') + expect(result).toContain('Removed block: Legacy Transform (function)') + expect(result).toContain( + 'Modified AI Assistant: Model changed from "gpt-4o" to "claude-sonnet-4-5"' + ) + expect(result).toContain('Modified AI Assistant: Temperature changed from "0.7" to "0.3"') + expect(result).toContain('Modified Slack Alert: Channel changed from "#general" to "#alerts"') + expect(result).toContain('Added connection: AI Assistant -> Gmail Notifications') + expect(result).toContain('Variables: added "errorCount"') + }) +}) + +describe('formatDiffSummaryForDescriptionAsync', () => { + it('resolves dropdown values to labels', async () => { + mockGetBlock.mockReturnValue({ + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { id: 'calendly_get_current_user', label: 'Get Current User' }, + { id: 'calendly_list_event_types', label: 'List Event Types' }, + ], + }, + ], + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'calendly', + name: 'Calendly', + changes: [ + { + field: 'operation', + oldValue: 'calendly_get_current_user', + newValue: 'calendly_list_event_types', + }, + ], + }, + ], + }) + + const mockState = { blocks: {} } as any + const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1') + expect(result).toContain( + 'Modified Calendly: Operation changed from "Get Current User" to "List Event Types"' + ) + expect(result).not.toContain('calendly_get_current_user') + }) + + it('uses field titles in async path', async () => { + mockGetBlock.mockReturnValue({ + subBlocks: [{ id: 'systemPrompt', title: 'System Prompt' }], + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'Agent', + changes: [{ field: 'systemPrompt', oldValue: 'Be helpful', newValue: 'Be concise' }], + }, + ], + }) + + const mockState = { blocks: {} } as any + const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1') + expect(result).toContain('System Prompt') + expect(result).not.toContain('systemPrompt') + }) + + it('filters .properties changes in async path', async () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'Agent', + changes: [ + { field: 'prompt', oldValue: 'old', newValue: 'new' }, + { field: 'prompt.properties', oldValue: {}, newValue: { x: 1 } }, + ], + }, + ], + }) + + const mockState = { blocks: {} } as any + const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1') + expect(result).not.toContain('.properties') + }) +}) + +describe('end-to-end: generateWorkflowDiffSummary + formatDiffSummaryForDescription', () => { + beforeEach(() => { + mockGetBlock.mockReturnValue(null) + }) + + it('detects added and removed blocks between two workflow versions', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Summarizer') + .connect('start', 'agent-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Summarizer') + .addFunction('func-1', undefined, 'Formatter') + .connect('start', 'agent-1') + .connect('agent-1', 'func-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Formatter (function)') + expect(result).toContain('Added connection: Summarizer -> Formatter') + expect(result).not.toContain('Removed') + }) + + it('detects block removal and edge removal', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Classifier') + .addFunction('func-1', undefined, 'Logger') + .connect('start', 'agent-1') + .connect('agent-1', 'func-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Classifier') + .connect('start', 'agent-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Removed block: Logger (function)') + expect(result).toContain('Removed connection: Classifier -> Logger') + expect(result).not.toContain('Added block') + }) + + it('detects subBlock value changes on modified blocks', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Writer') + .connect('start', 'agent-1') + .build() + previous.blocks['agent-1'].subBlocks = { + systemPrompt: { id: 'systemPrompt', value: 'You are a helpful assistant' }, + model: { id: 'model', value: 'gpt-4o' }, + } + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Writer') + .connect('start', 'agent-1') + .build() + current.blocks['agent-1'].subBlocks = { + systemPrompt: { id: 'systemPrompt', value: 'You are a concise writer' }, + model: { id: 'model', value: 'claude-sonnet-4-5' }, + } + + mockGetBlock.mockReturnValue({ + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + ], + }) + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain( + 'Modified Writer: System Prompt changed from "You are a helpful assistant" to "You are a concise writer"' + ) + expect(result).toContain('Modified Writer: Model changed from "gpt-4o" to "claude-sonnet-4-5"') + }) + + it('detects loop addition with correct count', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Process') + .connect('start', 'func-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Process') + .addLoop('loop-1', undefined, { iterations: 5, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'func-1') + .connect('func-1', 'loop-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Loop (loop)') + expect(result).toContain('Added block: loop-body (function)') + expect(result).toContain('Added 1 loop(s)') + expect(result).toContain('Added connection: Process -> Loop') + }) + + it('detects loop removal', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'agent') + .connect('start', 'loop-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Direct Agent') + .connect('start', 'agent-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Removed block: Loop (loop)') + expect(result).toContain('Removed 1 loop(s)') + expect(result).toContain('Added block: Direct Agent (agent)') + }) + + it('detects loop modification when iterations change', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 10, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Modified 1 loop(s)') + }) + + it('detects parallel addition', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Sequencer') + .connect('start', 'func-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 3, parallelType: 'count' }) + .addParallelChild('par-1', 'par-task-1', 'agent') + .addParallelChild('par-1', 'par-task-2', 'function') + .connect('start', 'par-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Parallel (parallel)') + expect(result).toContain('Added 1 parallel group(s)') + expect(result).toContain('Removed block: Sequencer (function)') + }) + + it('detects parallel removal', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 2 }) + .addParallelChild('par-1', 'par-task', 'function') + .connect('start', 'par-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Simple Step') + .connect('start', 'func-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Removed block: Parallel (parallel)') + expect(result).toContain('Removed 1 parallel group(s)') + expect(result).toContain('Added block: Simple Step (function)') + }) + + it('detects parallel modification when count changes', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 2, parallelType: 'count' }) + .addParallelChild('par-1', 'par-task', 'function') + .connect('start', 'par-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 5, parallelType: 'count' }) + .addParallelChild('par-1', 'par-task', 'function') + .connect('start', 'par-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Modified 1 parallel group(s)') + }) + + it('detects variable additions and removals with names', () => { + const previous = new WorkflowBuilder().addStarter('start').build() + previous.variables = { + v1: { id: 'v1', name: 'retryCount', type: 'number', value: 3 }, + v2: { id: 'v2', name: 'apiEndpoint', type: 'string', value: 'https://api.example.com' }, + } + + const current = new WorkflowBuilder().addStarter('start').build() + current.variables = { + v1: { id: 'v1', name: 'retryCount', type: 'number', value: 5 }, + v3: { id: 'v3', name: 'timeout', type: 'number', value: 30 }, + } + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Variables:') + expect(result).toContain('added "timeout"') + expect(result).toContain('removed "apiEndpoint"') + expect(result).toContain('modified "retryCount"') + }) + + it('produces no-change message for identical workflows', () => { + const workflow = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Agent') + .connect('start', 'agent-1') + .build() + + const summary = generateWorkflowDiffSummary(workflow, workflow) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toBe('No structural changes detected (configuration may have changed)') + }) + + it('handles complex scenario: loop replaced with parallel + new connections + variables', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 5 }) + .addLoopChild('loop-1', 'loop-task', 'agent') + .addFunction('sink', undefined, 'Output') + .connect('start', 'loop-1') + .connect('loop-1', 'sink') + .build() + previous.variables = { + v1: { id: 'v1', name: 'batchSize', type: 'number', value: 10 }, + } + + const current = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 3 }) + .addParallelChild('par-1', 'par-task', 'agent') + .addFunction('sink', undefined, 'Output') + .addAgent('agg', undefined, 'Aggregator') + .connect('start', 'par-1') + .connect('par-1', 'agg') + .connect('agg', 'sink') + .build() + current.variables = { + v1: { id: 'v1', name: 'batchSize', type: 'number', value: 25 }, + v2: { id: 'v2', name: 'concurrency', type: 'number', value: 3 }, + } + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Parallel (parallel)') + expect(result).toContain('Added block: Aggregator (agent)') + expect(result).toContain('Removed block: Loop (loop)') + expect(result).toContain('Added 1 parallel group(s)') + expect(result).toContain('Removed 1 loop(s)') + expect(result).toContain('added "concurrency"') + expect(result).toContain('modified "batchSize"') + + const lines = result.split('\n') + expect(lines.length).toBeGreaterThanOrEqual(7) + }) + + it('detects edge rewiring without block changes', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('a', undefined, 'Agent A') + .addAgent('b', undefined, 'Agent B') + .addFunction('sink', undefined, 'Output') + .connect('start', 'a') + .connect('a', 'sink') + .connect('start', 'b') + .connect('b', 'sink') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('a', undefined, 'Agent A') + .addAgent('b', undefined, 'Agent B') + .addFunction('sink', undefined, 'Output') + .connect('start', 'a') + .connect('a', 'b') + .connect('b', 'sink') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(summary.addedBlocks).toHaveLength(0) + expect(summary.removedBlocks).toHaveLength(0) + expect(result).toContain('Added connection: Agent A -> Agent B') + expect(result).toContain('Removed connection:') + expect(result).not.toContain('Added block') + expect(result).not.toContain('Removed block') + }) + + it('detects data field changes with human-readable labels', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addBlock('custom-1', 'function', undefined, 'Processor') + .connect('start', 'custom-1') + .build() + previous.blocks['custom-1'].data = { isStarter: true, retryPolicy: 'linear' } + + const current = new WorkflowBuilder() + .addStarter('start') + .addBlock('custom-1', 'function', undefined, 'Processor') + .connect('start', 'custom-1') + .build() + current.blocks['custom-1'].data = { isStarter: false, retryPolicy: 'exponential' } + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Is Starter') + expect(result).toContain('Retry Policy') + expect(result).toContain('enabled') + expect(result).toContain('disabled') + expect(result).toContain('linear') + expect(result).toContain('exponential') + }) + + it('detects loop type change via loop config modification', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'forEach' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Modified 1 loop(s)') + }) +}) diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 2fe7f24a34d..e66d76b8bce 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -9,6 +9,7 @@ import { getSelectorDefinition } from '@/hooks/selectors/registry' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { formatParameterLabel } from '@/tools/params' const logger = createLogger('ResolveValues') @@ -126,6 +127,33 @@ function extractMcpToolName(toolId: string): string { return withoutPrefix } +/** + * Resolves a subBlock field ID to its human-readable title. + * Falls back to the raw ID if the block or subBlock is not found. + */ +export function resolveFieldLabel(blockType: string, subBlockId: string): string { + if (subBlockId.startsWith('data.')) { + return formatParameterLabel(subBlockId.slice(5)) + } + const blockConfig = getBlock(blockType) + if (!blockConfig) return subBlockId + const subBlockConfig = blockConfig.subBlocks.find((sb) => sb.id === subBlockId) + return subBlockConfig?.title ?? subBlockId +} + +/** + * Resolves a dropdown option ID to its human-readable label. + * Returns null if the subBlock is not a dropdown or the value is not found. + */ +function resolveDropdownLabel(subBlockConfig: SubBlockConfig, value: string): string | null { + if (subBlockConfig.type !== 'dropdown') return null + if (!subBlockConfig.options) return null + const options = + typeof subBlockConfig.options === 'function' ? subBlockConfig.options() : subBlockConfig.options + const match = options.find((opt) => opt.id === value) + return match?.label ?? null +} + /** * Formats a value for display in diff descriptions. */ @@ -138,7 +166,10 @@ export function formatValueForDisplay(value: unknown): string { if (typeof value === 'boolean') return value ? 'enabled' : 'disabled' if (typeof value === 'number') return String(value) if (Array.isArray(value)) return `[${value.length} items]` - if (typeof value === 'object') return `${JSON.stringify(value).slice(0, 50)}...` + if (typeof value === 'object') { + const json = JSON.stringify(value) + return json.length > 50 ? `${json.slice(0, 50)}...` : json + } return String(value) } @@ -165,7 +196,6 @@ export async function resolveValueForDisplay( value: unknown, context: ResolutionContext ): Promise { - // Non-string or empty values can't be resolved if (typeof value !== 'string' || !value) { return { original: value, @@ -190,9 +220,8 @@ export async function resolveValueForDisplay( ) : { workflowId: context.workflowId, workspaceId: context.workspaceId } - // Credential fields (oauth-input or credential subBlockId) const isCredentialField = - subBlockConfig?.type === 'oauth-input' || context.subBlockId === 'credential' + subBlockConfig.type === 'oauth-input' || context.subBlockId === 'credential' if (isCredentialField && (value.startsWith(CREDENTIAL_SET.PREFIX) || isUuid(value))) { const label = await resolveCredential(value, context.workflowId) @@ -202,8 +231,7 @@ export async function resolveValueForDisplay( return { original: value, displayLabel: semanticFallback, resolved: true } } - // Workflow selector - if (subBlockConfig?.type === 'workflow-selector' && isUuid(value)) { + if (subBlockConfig.type === 'workflow-selector' && isUuid(value)) { const label = await resolveWorkflow(value, selectorCtx.workspaceId) if (label) { return { original: value, displayLabel: label, resolved: true } @@ -211,15 +239,27 @@ export async function resolveValueForDisplay( return { original: value, displayLabel: semanticFallback, resolved: true } } - // MCP tool selector - if (subBlockConfig?.type === 'mcp-tool-selector') { + if (subBlockConfig.type === 'mcp-tool-selector') { const toolName = extractMcpToolName(value) return { original: value, displayLabel: toolName, resolved: true } } - // Selector types that require hydration (file-selector, sheet-selector, etc.) - // These support external service IDs like Google Drive file IDs - if (subBlockConfig && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) { + if (subBlockConfig.type === 'dropdown') { + try { + const label = resolveDropdownLabel(subBlockConfig, value) + if (label) { + return { original: value, displayLabel: label, resolved: true } + } + } catch (error) { + logger.warn('Failed to resolve dropdown label', { + value, + subBlockId: context.subBlockId, + error, + }) + } + } + + if (SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) { const resolution = resolveSelectorForSubBlock(subBlockConfig, selectorCtx) if (resolution?.key) { @@ -228,22 +268,17 @@ export async function resolveValueForDisplay( return { original: value, displayLabel: label, resolved: true } } } - // If resolution failed for a hydration-required type, use semantic fallback return { original: value, displayLabel: semanticFallback, resolved: true } } - // For fields without specific subBlock types, use pattern matching - // UUID fallback if (isUuid(value)) { return { original: value, displayLabel: semanticFallback, resolved: true } } - // Slack-style IDs (channels: C..., users: U.../W...) get semantic fallback if (/^C[A-Z0-9]{8,}$/.test(value) || /^[UW][A-Z0-9]{8,}$/.test(value)) { return { original: value, displayLabel: semanticFallback, resolved: true } } - // Credential set prefix without credential field type if (value.startsWith(CREDENTIAL_SET.PREFIX)) { const label = await resolveCredential(value, context.workflowId) if (label) { diff --git a/apps/sim/tools/jsm/get_form_structure.ts b/apps/sim/tools/jsm/get_form_structure.ts new file mode 100644 index 00000000000..48193e37972 --- /dev/null +++ b/apps/sim/tools/jsm/get_form_structure.ts @@ -0,0 +1,121 @@ +import type { JsmGetFormStructureParams, JsmGetFormStructureResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetFormStructureTool: ToolConfig< + JsmGetFormStructureParams, + JsmGetFormStructureResponse +> = { + id: 'jsm_get_form_structure', + name: 'JSM Get Form Structure', + description: + 'Get the full structure of a ProForma/JSM form including all questions, field types, choices, layout, and conditions', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + projectIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Jira project ID or key (e.g., "10001" or "SD")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form ID (UUID from Get Form Templates)', + }, + }, + + request: { + url: '/api/tools/jsm/forms/structure', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + projectIdOrKey: params.projectIdOrKey, + formId: params.formId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + projectIdOrKey: '', + formId: '', + design: null, + updated: null, + publish: null, + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + projectIdOrKey: '', + formId: '', + design: null, + updated: null, + publish: null, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + projectIdOrKey: { type: 'string', description: 'Project ID or key' }, + formId: { type: 'string', description: 'Form ID' }, + design: { + type: 'json', + description: + 'Full form design with questions (field types, labels, choices, validation), layout (field ordering), and conditions', + }, + updated: { type: 'string', description: 'Last updated timestamp', optional: true }, + publish: { + type: 'json', + description: 'Publishing and request type configuration', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/jsm/get_form_templates.ts b/apps/sim/tools/jsm/get_form_templates.ts new file mode 100644 index 00000000000..b29652f1764 --- /dev/null +++ b/apps/sim/tools/jsm/get_form_templates.ts @@ -0,0 +1,108 @@ +import type { JsmGetFormTemplatesParams, JsmGetFormTemplatesResponse } from '@/tools/jsm/types' +import { FORM_TEMPLATE_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetFormTemplatesTool: ToolConfig< + JsmGetFormTemplatesParams, + JsmGetFormTemplatesResponse +> = { + id: 'jsm_get_form_templates', + name: 'JSM Get Form Templates', + description: + 'List forms (ProForma/JSM Forms) in a Jira project to discover form IDs for request types', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + projectIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Jira project ID or key (e.g., "10001" or "SD")', + }, + }, + + request: { + url: '/api/tools/jsm/forms/templates', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + projectIdOrKey: params.projectIdOrKey, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + projectIdOrKey: '', + templates: [], + total: 0, + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + projectIdOrKey: '', + templates: [], + total: 0, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + projectIdOrKey: { type: 'string', description: 'Project ID or key' }, + templates: { + type: 'array', + description: 'List of forms in the project', + items: { + type: 'object', + properties: FORM_TEMPLATE_PROPERTIES, + }, + }, + total: { type: 'number', description: 'Total number of forms' }, + }, +} diff --git a/apps/sim/tools/jsm/get_issue_forms.ts b/apps/sim/tools/jsm/get_issue_forms.ts new file mode 100644 index 00000000000..764fd20856f --- /dev/null +++ b/apps/sim/tools/jsm/get_issue_forms.ts @@ -0,0 +1,105 @@ +import type { JsmGetIssueFormsParams, JsmGetIssueFormsResponse } from '@/tools/jsm/types' +import { ISSUE_FORM_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetIssueFormsTool: ToolConfig = { + id: 'jsm_get_issue_forms', + name: 'JSM Get Issue Forms', + description: + 'List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123", "10001")', + }, + }, + + request: { + url: '/api/tools/jsm/forms/issue', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + issueIdOrKey: '', + forms: [], + total: 0, + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + forms: [], + total: 0, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + forms: { + type: 'array', + description: 'List of forms attached to the issue', + items: { + type: 'object', + properties: ISSUE_FORM_PROPERTIES, + }, + }, + total: { type: 'number', description: 'Total number of forms' }, + }, +} diff --git a/apps/sim/tools/jsm/index.ts b/apps/sim/tools/jsm/index.ts index 56cd5f1029b..8cf000e4703 100644 --- a/apps/sim/tools/jsm/index.ts +++ b/apps/sim/tools/jsm/index.ts @@ -8,6 +8,9 @@ import { jsmCreateRequestTool } from '@/tools/jsm/create_request' import { jsmGetApprovalsTool } from '@/tools/jsm/get_approvals' import { jsmGetCommentsTool } from '@/tools/jsm/get_comments' import { jsmGetCustomersTool } from '@/tools/jsm/get_customers' +import { jsmGetFormStructureTool } from '@/tools/jsm/get_form_structure' +import { jsmGetFormTemplatesTool } from '@/tools/jsm/get_form_templates' +import { jsmGetIssueFormsTool } from '@/tools/jsm/get_issue_forms' import { jsmGetOrganizationsTool } from '@/tools/jsm/get_organizations' import { jsmGetParticipantsTool } from '@/tools/jsm/get_participants' import { jsmGetQueuesTool } from '@/tools/jsm/get_queues' @@ -31,6 +34,9 @@ export { jsmGetApprovalsTool, jsmGetCommentsTool, jsmGetCustomersTool, + jsmGetFormStructureTool, + jsmGetFormTemplatesTool, + jsmGetIssueFormsTool, jsmGetOrganizationsTool, jsmGetParticipantsTool, jsmGetQueuesTool, diff --git a/apps/sim/tools/jsm/types.ts b/apps/sim/tools/jsm/types.ts index abd96ac53ea..b76b6dbfdb8 100644 --- a/apps/sim/tools/jsm/types.ts +++ b/apps/sim/tools/jsm/types.ts @@ -222,6 +222,44 @@ export const REQUEST_TYPE_FIELD_PROPERTIES = { }, } as const +/** Output properties for a FormTemplateIndexEntry (list endpoint) per OpenAPI spec */ +export const FORM_TEMPLATE_PROPERTIES = { + id: { type: 'string', description: 'Form template ID (UUID)' }, + name: { type: 'string', description: 'Form template name' }, + updated: { type: 'string', description: 'Last updated timestamp (ISO 8601)' }, + issueCreateIssueTypeIds: { + type: 'json', + description: 'Issue type IDs that auto-attach this form on issue create', + }, + issueCreateRequestTypeIds: { + type: 'json', + description: 'Request type IDs that auto-attach this form on issue create', + }, + portalRequestTypeIds: { + type: 'json', + description: 'Request type IDs that show this form on the customer portal', + }, + recommendedIssueRequestTypeIds: { + type: 'json', + description: 'Request type IDs that recommend this form', + }, +} as const + +/** Output properties for a FormIndexEntry (issue forms list endpoint) per OpenAPI spec */ +export const ISSUE_FORM_PROPERTIES = { + id: { type: 'string', description: 'Form instance ID (UUID)' }, + name: { type: 'string', description: 'Form name' }, + updated: { type: 'string', description: 'Last updated timestamp (ISO 8601)' }, + submitted: { type: 'boolean', description: 'Whether the form has been submitted' }, + lock: { type: 'boolean', description: 'Whether the form is locked' }, + internal: { type: 'boolean', description: 'Whether the form is internal-only', optional: true }, + formTemplateId: { + type: 'string', + description: 'Source form template ID (UUID)', + optional: true, + }, +} as const + // --------------------------------------------------------------------------- // Data model interfaces // --------------------------------------------------------------------------- @@ -778,6 +816,89 @@ export interface JsmGetRequestTypeFieldsResponse extends ToolResponse { } } +export interface JsmGetFormTemplatesParams extends JsmBaseParams { + projectIdOrKey: string +} + +export interface JsmGetFormStructureParams extends JsmBaseParams { + projectIdOrKey: string + formId: string +} + +export interface JsmGetIssueFormsParams extends JsmBaseParams { + issueIdOrKey: string +} + +/** FormQuestion per OpenAPI spec */ +export interface JsmFormQuestion { + label: string + type: string + validation: { rq?: boolean; [key: string]: unknown } + choices?: Array<{ id: string; label: string; other?: boolean }> + dcId?: string + defaultAnswer?: Record + description?: string + jiraField?: string + questionKey?: string +} + +/** FormTemplateIndexEntry per OpenAPI spec */ +export interface JsmFormTemplate { + id: string + name: string + updated: string + issueCreateIssueTypeIds: number[] + issueCreateRequestTypeIds: number[] + portalRequestTypeIds: number[] + recommendedIssueRequestTypeIds: number[] +} + +/** FormIndexEntry (issue form) per OpenAPI spec */ +export interface JsmIssueForm { + id: string + name: string + updated: string + submitted: boolean + lock: boolean + internal?: boolean + formTemplateId?: string +} + +export interface JsmGetFormTemplatesResponse extends ToolResponse { + output: { + ts: string + projectIdOrKey: string + templates: JsmFormTemplate[] + total: number + } +} + +export interface JsmGetFormStructureResponse extends ToolResponse { + output: { + ts: string + projectIdOrKey: string + formId: string + design: { + questions: Record + layout: unknown[] + conditions: Record + sections: Record + settings: { name: string; submit: { lock: boolean; pdf: boolean }; language?: string } + } | null + updated: string | null + publish: Record | null + } +} + +export interface JsmGetIssueFormsResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + forms: JsmIssueForm[] + total: number + } +} + // --------------------------------------------------------------------------- // Union type for all JSM responses // --------------------------------------------------------------------------- @@ -805,3 +926,6 @@ export type JsmResponse = | JsmGetApprovalsResponse | JsmAnswerApprovalResponse | JsmGetRequestTypeFieldsResponse + | JsmGetFormTemplatesResponse + | JsmGetFormStructureResponse + | JsmGetIssueFormsResponse diff --git a/apps/sim/tools/jsm/utils.ts b/apps/sim/tools/jsm/utils.ts index b523e6ba2c4..00815472585 100644 --- a/apps/sim/tools/jsm/utils.ts +++ b/apps/sim/tools/jsm/utils.ts @@ -13,6 +13,15 @@ export function getJsmApiBaseUrl(cloudId: string): string { return `https://api.atlassian.com/ex/jira/${cloudId}/rest/servicedeskapi` } +/** + * Build the base URL for JSM Forms (ProForma) API + * @param cloudId - The Jira Cloud ID + * @returns The base URL for the JSM Forms API + */ +export function getJsmFormsApiBaseUrl(cloudId: string): string { + return `https://api.atlassian.com/jira/forms/cloud/${cloudId}` +} + /** * Build common headers for JSM API requests * @param accessToken - The OAuth access token @@ -26,3 +35,28 @@ export function getJsmHeaders(accessToken: string): Record { 'X-ExperimentalApi': 'opt-in', } } + +/** + * Parse error messages from JSM/Forms API responses + * @param status - HTTP status code + * @param statusText - HTTP status text + * @param errorText - Raw error response body + * @returns Formatted error message string + */ +export function parseJsmErrorMessage( + status: number, + statusText: string, + errorText: string +): string { + try { + const errorData = JSON.parse(errorText) + if (errorData.errorMessage) { + return `JSM Forms API error: ${errorData.errorMessage}` + } + } catch { + if (errorText) { + return `JSM Forms API error: ${errorText}` + } + } + return `JSM Forms API error: ${status} ${statusText}` +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 037cc9d716e..76b98a0d87c 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1292,6 +1292,9 @@ import { jsmGetApprovalsTool, jsmGetCommentsTool, jsmGetCustomersTool, + jsmGetFormStructureTool, + jsmGetFormTemplatesTool, + jsmGetIssueFormsTool, jsmGetOrganizationsTool, jsmGetParticipantsTool, jsmGetQueuesTool, @@ -3093,6 +3096,9 @@ export const tools: Record = { jsm_add_participants: jsmAddParticipantsTool, jsm_get_approvals: jsmGetApprovalsTool, jsm_answer_approval: jsmAnswerApprovalTool, + jsm_get_form_templates: jsmGetFormTemplatesTool, + jsm_get_form_structure: jsmGetFormStructureTool, + jsm_get_issue_forms: jsmGetIssueFormsTool, kalshi_get_markets: kalshiGetMarketsTool, kalshi_get_markets_v2: kalshiGetMarketsV2Tool, kalshi_get_market: kalshiGetMarketTool, diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 2f12cf3ab53..40d3f825c76 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ 'isolated-vm', 'pptxgenjs', 'react-dom', + '@react-email/components', '@react-email/render', ], }), diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 6db1582457c..1e7cf2b3c8b 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -235,6 +235,13 @@ import { salesforceRecordUpdatedTrigger, salesforceWebhookTrigger, } from '@/triggers/salesforce' +import { + servicenowChangeRequestCreatedTrigger, + servicenowChangeRequestUpdatedTrigger, + servicenowIncidentCreatedTrigger, + servicenowIncidentUpdatedTrigger, + servicenowWebhookTrigger, +} from '@/triggers/servicenow' import { slackWebhookTrigger } from '@/triggers/slack' import { stripeWebhookTrigger } from '@/triggers/stripe' import { telegramWebhookTrigger } from '@/triggers/telegram' @@ -437,6 +444,11 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { salesforce_opportunity_stage_changed: salesforceOpportunityStageChangedTrigger, salesforce_case_status_changed: salesforceCaseStatusChangedTrigger, salesforce_webhook: salesforceWebhookTrigger, + servicenow_incident_created: servicenowIncidentCreatedTrigger, + servicenow_incident_updated: servicenowIncidentUpdatedTrigger, + servicenow_change_request_created: servicenowChangeRequestCreatedTrigger, + servicenow_change_request_updated: servicenowChangeRequestUpdatedTrigger, + servicenow_webhook: servicenowWebhookTrigger, stripe_webhook: stripeWebhookTrigger, telegram_webhook: telegramWebhookTrigger, typeform_webhook: typeformWebhookTrigger, diff --git a/apps/sim/triggers/servicenow/change_request_created.ts b/apps/sim/triggers/servicenow/change_request_created.ts new file mode 100644 index 00000000000..bd538158dd7 --- /dev/null +++ b/apps/sim/triggers/servicenow/change_request_created.ts @@ -0,0 +1,37 @@ +import { ServiceNowIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildChangeRequestOutputs, + buildServiceNowExtraFields, + servicenowSetupInstructions, + servicenowTriggerOptions, +} from '@/triggers/servicenow/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * ServiceNow Change Request Created Trigger + */ +export const servicenowChangeRequestCreatedTrigger: TriggerConfig = { + id: 'servicenow_change_request_created', + name: 'ServiceNow Change Request Created', + provider: 'servicenow', + description: 'Trigger workflow when a new change request is created in ServiceNow', + version: '1.0.0', + icon: ServiceNowIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'servicenow_change_request_created', + triggerOptions: servicenowTriggerOptions, + setupInstructions: servicenowSetupInstructions('Insert (record creation)'), + extraFields: buildServiceNowExtraFields('servicenow_change_request_created'), + }), + + outputs: buildChangeRequestOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/servicenow/change_request_updated.ts b/apps/sim/triggers/servicenow/change_request_updated.ts new file mode 100644 index 00000000000..f7148f90583 --- /dev/null +++ b/apps/sim/triggers/servicenow/change_request_updated.ts @@ -0,0 +1,37 @@ +import { ServiceNowIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildChangeRequestOutputs, + buildServiceNowExtraFields, + servicenowSetupInstructions, + servicenowTriggerOptions, +} from '@/triggers/servicenow/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * ServiceNow Change Request Updated Trigger + */ +export const servicenowChangeRequestUpdatedTrigger: TriggerConfig = { + id: 'servicenow_change_request_updated', + name: 'ServiceNow Change Request Updated', + provider: 'servicenow', + description: 'Trigger workflow when a change request is updated in ServiceNow', + version: '1.0.0', + icon: ServiceNowIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'servicenow_change_request_updated', + triggerOptions: servicenowTriggerOptions, + setupInstructions: servicenowSetupInstructions('Update (record modification)'), + extraFields: buildServiceNowExtraFields('servicenow_change_request_updated'), + }), + + outputs: buildChangeRequestOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/servicenow/incident_created.ts b/apps/sim/triggers/servicenow/incident_created.ts new file mode 100644 index 00000000000..170ab357296 --- /dev/null +++ b/apps/sim/triggers/servicenow/incident_created.ts @@ -0,0 +1,40 @@ +import { ServiceNowIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIncidentOutputs, + buildServiceNowExtraFields, + servicenowSetupInstructions, + servicenowTriggerOptions, +} from '@/triggers/servicenow/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * ServiceNow Incident Created Trigger + * + * Primary trigger — includes the dropdown for selecting trigger type. + */ +export const servicenowIncidentCreatedTrigger: TriggerConfig = { + id: 'servicenow_incident_created', + name: 'ServiceNow Incident Created', + provider: 'servicenow', + description: 'Trigger workflow when a new incident is created in ServiceNow', + version: '1.0.0', + icon: ServiceNowIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'servicenow_incident_created', + triggerOptions: servicenowTriggerOptions, + includeDropdown: true, + setupInstructions: servicenowSetupInstructions('Insert (record creation)'), + extraFields: buildServiceNowExtraFields('servicenow_incident_created'), + }), + + outputs: buildIncidentOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/servicenow/incident_updated.ts b/apps/sim/triggers/servicenow/incident_updated.ts new file mode 100644 index 00000000000..70c9914d6a6 --- /dev/null +++ b/apps/sim/triggers/servicenow/incident_updated.ts @@ -0,0 +1,37 @@ +import { ServiceNowIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIncidentOutputs, + buildServiceNowExtraFields, + servicenowSetupInstructions, + servicenowTriggerOptions, +} from '@/triggers/servicenow/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * ServiceNow Incident Updated Trigger + */ +export const servicenowIncidentUpdatedTrigger: TriggerConfig = { + id: 'servicenow_incident_updated', + name: 'ServiceNow Incident Updated', + provider: 'servicenow', + description: 'Trigger workflow when an incident is updated in ServiceNow', + version: '1.0.0', + icon: ServiceNowIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'servicenow_incident_updated', + triggerOptions: servicenowTriggerOptions, + setupInstructions: servicenowSetupInstructions('Update (record modification)'), + extraFields: buildServiceNowExtraFields('servicenow_incident_updated'), + }), + + outputs: buildIncidentOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/servicenow/index.ts b/apps/sim/triggers/servicenow/index.ts new file mode 100644 index 00000000000..adb585ff29b --- /dev/null +++ b/apps/sim/triggers/servicenow/index.ts @@ -0,0 +1,5 @@ +export { servicenowChangeRequestCreatedTrigger } from './change_request_created' +export { servicenowChangeRequestUpdatedTrigger } from './change_request_updated' +export { servicenowIncidentCreatedTrigger } from './incident_created' +export { servicenowIncidentUpdatedTrigger } from './incident_updated' +export { servicenowWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/servicenow/utils.ts b/apps/sim/triggers/servicenow/utils.ts new file mode 100644 index 00000000000..7c85b659618 --- /dev/null +++ b/apps/sim/triggers/servicenow/utils.ts @@ -0,0 +1,280 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all ServiceNow triggers + */ +export const servicenowTriggerOptions = [ + { label: 'Incident Created', id: 'servicenow_incident_created' }, + { label: 'Incident Updated', id: 'servicenow_incident_updated' }, + { label: 'Change Request Created', id: 'servicenow_change_request_created' }, + { label: 'Change Request Updated', id: 'servicenow_change_request_updated' }, + { label: 'Generic Webhook (All Events)', id: 'servicenow_webhook' }, +] + +/** + * Generates setup instructions for ServiceNow webhooks. + * ServiceNow uses Business Rules with RESTMessageV2 for outbound webhooks. + */ +export function servicenowSetupInstructions(eventType: string): string { + const instructions = [ + 'Note: You need admin or developer permissions in your ServiceNow instance to create Business Rules.', + 'Navigate to System Definition > Business Rules and create a new Business Rule.', + `Set the table (e.g., incident, change_request), set When to after, and check ${eventType}.`, + 'Check the Advanced checkbox to enable the script editor.', + 'Copy the Webhook URL above and generate a Webhook Secret (any strong random string). Paste the secret in the Webhook Secret field here.', + `In the script, use RESTMessageV2 to POST the record data as JSON to the Webhook URL above. Include the secret as Authorization: Bearer <your secret> or X-Sim-Webhook-Secret: <your secret>. Example:
var r = new sn_ws.RESTMessageV2();\nr.setEndpoint("<webhook_url>");\nr.setHttpMethod("POST");\nr.setRequestHeader("Content-Type", "application/json");\nr.setRequestHeader("Authorization", "Bearer <your_webhook_secret>");\nr.setRequestBody(JSON.stringify({\n sysId: current.sys_id.toString(),\n number: current.number.toString(),\n shortDescription: current.short_description.toString(),\n state: current.state.toString(),\n priority: current.priority.toString()\n}));\nr.execute();`, + 'Activate the Business Rule and click "Save" above to activate your trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + +/** + * Webhook secret field for ServiceNow triggers + */ +function servicenowWebhookSecretField(triggerId: string): SubBlockConfig { + return { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Generate a secret and paste it here', + description: + 'Required. Use the same value in your ServiceNow Business Rule as Bearer token or X-Sim-Webhook-Secret.', + password: true, + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + } +} + +/** + * Extra fields for ServiceNow triggers (webhook secret + optional table filter) + */ +export function buildServiceNowExtraFields(triggerId: string): SubBlockConfig[] { + return [ + servicenowWebhookSecretField(triggerId), + { + id: 'tableName', + title: 'Table Name (Optional)', + type: 'short-input', + placeholder: 'e.g., incident, change_request', + description: 'Optionally filter to a specific ServiceNow table', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Common record fields shared across ServiceNow trigger outputs + */ +function buildRecordOutputs(): Record { + return { + sysId: { type: 'string', description: 'Unique system ID of the record' }, + number: { type: 'string', description: 'Record number (e.g., INC0010001, CHG0010001)' }, + tableName: { type: 'string', description: 'ServiceNow table name' }, + shortDescription: { type: 'string', description: 'Short description of the record' }, + description: { type: 'string', description: 'Full description of the record' }, + state: { type: 'string', description: 'Current state of the record' }, + priority: { + type: 'string', + description: 'Priority level (1=Critical, 2=High, 3=Moderate, 4=Low, 5=Planning)', + }, + assignedTo: { type: 'string', description: 'User assigned to this record' }, + assignmentGroup: { type: 'string', description: 'Group assigned to this record' }, + createdBy: { type: 'string', description: 'User who created the record' }, + createdOn: { type: 'string', description: 'When the record was created (ISO 8601)' }, + updatedBy: { type: 'string', description: 'User who last updated the record' }, + updatedOn: { type: 'string', description: 'When the record was last updated (ISO 8601)' }, + } +} + +/** + * Outputs for incident triggers + */ +export function buildIncidentOutputs(): Record { + return { + ...buildRecordOutputs(), + urgency: { type: 'string', description: 'Urgency level (1=High, 2=Medium, 3=Low)' }, + impact: { type: 'string', description: 'Impact level (1=High, 2=Medium, 3=Low)' }, + category: { type: 'string', description: 'Incident category' }, + subcategory: { type: 'string', description: 'Incident subcategory' }, + caller: { type: 'string', description: 'Caller/requester of the incident' }, + resolvedBy: { type: 'string', description: 'User who resolved the incident' }, + resolvedAt: { type: 'string', description: 'When the incident was resolved' }, + closeNotes: { type: 'string', description: 'Notes added when the incident was closed' }, + record: { type: 'json', description: 'Full incident record data' }, + } +} + +/** + * Outputs for change request triggers + */ +export function buildChangeRequestOutputs(): Record { + return { + ...buildRecordOutputs(), + type: { type: 'string', description: 'Change type (Normal, Standard, Emergency)' }, + risk: { type: 'string', description: 'Risk level of the change' }, + impact: { type: 'string', description: 'Impact level of the change' }, + approval: { type: 'string', description: 'Approval status' }, + startDate: { type: 'string', description: 'Planned start date' }, + endDate: { type: 'string', description: 'Planned end date' }, + category: { type: 'string', description: 'Change category' }, + record: { type: 'json', description: 'Full change request record data' }, + } +} + +function normalizeToken(s: string): string { + return s + .trim() + .toLowerCase() + .replace(/[\s-]+/g, '_') +} + +/** + * Extracts the table name from a ServiceNow webhook payload. + * Business Rule scripts can send tableName in multiple formats. + */ +function extractTableName(body: Record): string | undefined { + const candidates = [body.tableName, body.table_name, body.table, body.sys_class_name] + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) { + return c.trim() + } + } + return undefined +} + +/** + * Extracts the event type from a ServiceNow webhook payload. + */ +function extractEventType(body: Record): string | undefined { + const candidates = [body.eventType, body.event_type, body.action, body.operation] + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) { + return c.trim() + } + } + return undefined +} + +const INCIDENT_CREATED = new Set([ + 'incident_created', + 'insert', + 'created', + 'create', + 'after_insert', + 'afterinsert', +]) + +const INCIDENT_UPDATED = new Set([ + 'incident_updated', + 'update', + 'updated', + 'after_update', + 'afterupdate', +]) + +const CHANGE_REQUEST_CREATED = new Set([ + 'change_request_created', + 'insert', + 'created', + 'create', + 'after_insert', + 'afterinsert', +]) + +const CHANGE_REQUEST_UPDATED = new Set([ + 'change_request_updated', + 'update', + 'updated', + 'after_update', + 'afterupdate', +]) + +/** + * Checks whether a ServiceNow webhook payload matches the configured trigger. + * Used by the ServiceNow provider handler to filter events at runtime. + */ +export function isServiceNowEventMatch( + triggerId: string, + body: Record, + configuredTableName?: string +): boolean { + const payloadTable = extractTableName(body) + const eventType = extractEventType(body) + + if (triggerId === 'servicenow_webhook') { + if (!configuredTableName?.trim()) { + return true + } + if (!payloadTable) { + return true + } + return normalizeToken(payloadTable) === normalizeToken(configuredTableName) + } + + if (triggerId === 'servicenow_incident_created' || triggerId === 'servicenow_incident_updated') { + if (configuredTableName?.trim()) { + if (payloadTable && normalizeToken(payloadTable) !== normalizeToken(configuredTableName)) { + return false + } + } else if (payloadTable && normalizeToken(payloadTable) !== 'incident') { + return false + } + + if (!eventType) { + return true + } + + const normalized = normalizeToken(eventType) + return triggerId === 'servicenow_incident_created' + ? INCIDENT_CREATED.has(normalized) + : INCIDENT_UPDATED.has(normalized) + } + + if ( + triggerId === 'servicenow_change_request_created' || + triggerId === 'servicenow_change_request_updated' + ) { + if (configuredTableName?.trim()) { + if (payloadTable && normalizeToken(payloadTable) !== normalizeToken(configuredTableName)) { + return false + } + } else if (payloadTable && normalizeToken(payloadTable) !== 'change_request') { + return false + } + + if (!eventType) { + return true + } + + const normalized = normalizeToken(eventType) + return triggerId === 'servicenow_change_request_created' + ? CHANGE_REQUEST_CREATED.has(normalized) + : CHANGE_REQUEST_UPDATED.has(normalized) + } + + return true +} + +/** + * Outputs for the generic webhook trigger (all events) + */ +export function buildServiceNowWebhookOutputs(): Record { + return { + ...buildRecordOutputs(), + eventType: { + type: 'string', + description: 'The type of event that triggered this workflow (e.g., insert, update, delete)', + }, + category: { type: 'string', description: 'Record category' }, + record: { type: 'json', description: 'Full record data from the webhook payload' }, + } +} diff --git a/apps/sim/triggers/servicenow/webhook.ts b/apps/sim/triggers/servicenow/webhook.ts new file mode 100644 index 00000000000..7cb1d19d5d7 --- /dev/null +++ b/apps/sim/triggers/servicenow/webhook.ts @@ -0,0 +1,38 @@ +import { ServiceNowIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildServiceNowExtraFields, + buildServiceNowWebhookOutputs, + servicenowSetupInstructions, + servicenowTriggerOptions, +} from '@/triggers/servicenow/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Generic ServiceNow Webhook Trigger + * Captures all ServiceNow webhook events + */ +export const servicenowWebhookTrigger: TriggerConfig = { + id: 'servicenow_webhook', + name: 'ServiceNow Webhook (All Events)', + provider: 'servicenow', + description: 'Trigger workflow on any ServiceNow webhook event', + version: '1.0.0', + icon: ServiceNowIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'servicenow_webhook', + triggerOptions: servicenowTriggerOptions, + setupInstructions: servicenowSetupInstructions('Insert, Update, or Delete'), + extraFields: buildServiceNowExtraFields('servicenow_webhook'), + }), + + outputs: buildServiceNowWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/bun.lock b/bun.lock index f8bde9a6cf3..e05bc532f5e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio",