Compare commits

..

2 Commits

202 changed files with 20801 additions and 2937 deletions

View File

@@ -88,7 +88,8 @@ Update a Confluence page using the Confluence API.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of update |
| `ts` | string | ISO 8601 timestamp of the operation |
| `success` | boolean | Operation success status |
| `pageId` | string | Confluence page ID |
| `title` | string | Updated page title |
| `status` | string | Page status |
@@ -110,7 +111,6 @@ Update a Confluence page using the Confluence API.
| ↳ `authorId` | string | Account ID of the version author |
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
| `url` | string | URL to view the page in Confluence |
| `success` | boolean | Update operation success status |
### `confluence_create_page`
@@ -131,7 +131,7 @@ Create a new page in a Confluence space.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of creation |
| `ts` | string | ISO 8601 timestamp of the operation |
| `pageId` | string | Created page ID |
| `title` | string | Page title |
| `status` | string | Page status |
@@ -172,9 +172,9 @@ Delete a Confluence page. By default moves to trash; use purge=true to permanent
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of deletion |
| `pageId` | string | Deleted page ID |
| `ts` | string | ISO 8601 timestamp of the operation |
| `deleted` | boolean | Deletion status |
| `pageId` | string | Deleted page ID |
### `confluence_list_pages_in_space`
@@ -358,10 +358,10 @@ List all custom properties (metadata) attached to a Confluence page.
| `ts` | string | ISO 8601 timestamp of the operation |
| `pageId` | string | ID of the page |
| `properties` | array | Array of content properties |
| ↳ `id` | string | Property ID |
| ↳ `key` | string | Property key |
| ↳ `id` | string | Unique property identifier |
| ↳ `key` | string | Property key/name |
| ↳ `value` | json | Property value \(can be any JSON\) |
| ↳ `version` | object | Version information |
| ↳ `version` | object | Property version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
@@ -388,16 +388,50 @@ Create a new custom property (metadata) on a Confluence page.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `pageId` | string | ID of the page |
| `propertyId` | string | ID of the created property |
| `key` | string | Property key |
| `value` | json | Property value |
| `version` | object | Version information |
| `id` | string | Unique property identifier |
| `key` | string | Property key/name |
| `value` | json | Property value \(can be any JSON\) |
| `version` | object | Property version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
| ↳ `authorId` | string | Account ID of the version author |
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
| `pageId` | string | ID of the page |
| `propertyId` | string | ID of the created property |
### `confluence_update_page_property`
Update an existing content property on a Confluence page.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | The ID of the page containing the property |
| `propertyId` | string | Yes | The ID of the property to update |
| `key` | string | Yes | The key/name of the property |
| `value` | json | Yes | The new value for the property \(can be any JSON value\) |
| `versionNumber` | number | Yes | The current version number of the property \(for conflict prevention\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `id` | string | Unique property identifier |
| `key` | string | Property key/name |
| `value` | json | Property value \(can be any JSON\) |
| `version` | object | Property version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
| ↳ `authorId` | string | Account ID of the version author |
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
| `pageId` | string | ID of the page |
| `propertyId` | string | ID of the updated property |
### `confluence_delete_page_property`
@@ -438,7 +472,7 @@ Search for content across Confluence pages, blog posts, and other content.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of search |
| `ts` | string | ISO 8601 timestamp of the operation |
| `results` | array | Array of search results |
| ↳ `id` | string | Unique content identifier |
| ↳ `title` | string | Content title |
@@ -512,19 +546,29 @@ List all blog posts across all accessible Confluence spaces.
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `blogPosts` | array | Array of blog posts |
| ↳ `id` | string | Blog post ID |
| ↳ `id` | string | Unique blog post identifier |
| ↳ `title` | string | Blog post title |
| ↳ `status` | string | Blog post status |
| ↳ `spaceId` | string | Space ID |
| ↳ `authorId` | string | Author account ID |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `version` | object | Version information |
| ↳ `status` | string | Blog post status \(e.g., current, draft\) |
| ↳ `spaceId` | string | ID of the space containing the blog post |
| ↳ `authorId` | string | Account ID of the blog post author |
| ↳ `createdAt` | string | ISO 8601 timestamp when the blog post was created |
| ↳ `version` | object | Blog post version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
| ↳ `authorId` | string | Account ID of the version author |
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
| ↳ `webUrl` | string | URL to view the blog post |
| ↳ `body` | object | Blog post body content |
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| ↳ `view` | object | Body in view format \(rendered HTML\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| ↳ `webUrl` | string | URL to view the blog post in Confluence |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_get_blogpost`
@@ -545,19 +589,19 @@ Get a specific Confluence blog post by ID, including its content.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `id` | string | Blog post ID |
| `id` | string | Unique blog post identifier |
| `title` | string | Blog post title |
| `status` | string | Blog post status |
| `spaceId` | string | Space ID |
| `authorId` | string | Author account ID |
| `createdAt` | string | Creation timestamp |
| `version` | object | Version information |
| `status` | string | Blog post status \(e.g., current, draft\) |
| `spaceId` | string | ID of the space containing the blog post |
| `authorId` | string | Account ID of the blog post author |
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
| `version` | object | Blog post version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
| ↳ `authorId` | string | Account ID of the version author |
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
| `body` | object | Blog post body content in requested format\(s\) |
| `body` | object | Blog post body content |
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
@@ -567,7 +611,7 @@ Get a specific Confluence blog post by ID, including its content.
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| `webUrl` | string | URL to view the blog post |
| `webUrl` | string | URL to view the blog post in Confluence |
### `confluence_create_blogpost`
@@ -589,11 +633,18 @@ Create a new blog post in a Confluence space.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `id` | string | Created blog post ID |
| `id` | string | Unique blog post identifier |
| `title` | string | Blog post title |
| `status` | string | Blog post status |
| `spaceId` | string | Space ID |
| `authorId` | string | Author account ID |
| `status` | string | Blog post status \(e.g., current, draft\) |
| `spaceId` | string | ID of the space containing the blog post |
| `authorId` | string | Account ID of the blog post author |
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
| `version` | object | Blog post version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
| ↳ `authorId` | string | Account ID of the version author |
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
| `body` | object | Blog post body content |
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
| ↳ `value` | string | The content value in the specified format |
@@ -604,13 +655,71 @@ Create a new blog post in a Confluence space.
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| `webUrl` | string | URL to view the blog post in Confluence |
### `confluence_update_blogpost`
Update an existing Confluence blog post title, content, or status.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `blogPostId` | string | Yes | The ID of the blog post to update |
| `title` | string | No | New title for the blog post |
| `content` | string | No | New content for the blog post in Confluence storage format |
| `status` | string | No | Blog post status: current or draft |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `id` | string | Unique blog post identifier |
| `title` | string | Blog post title |
| `status` | string | Blog post status \(e.g., current, draft\) |
| `spaceId` | string | ID of the space containing the blog post |
| `authorId` | string | Account ID of the blog post author |
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
| `version` | object | Blog post version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
| ↳ `authorId` | string | Account ID of the version author |
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
| `webUrl` | string | URL to view the blog post |
| `body` | object | Blog post body content |
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| ↳ `view` | object | Body in view format \(rendered HTML\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| `webUrl` | string | URL to view the blog post in Confluence |
### `confluence_delete_blogpost`
Delete a Confluence blog post.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `blogPostId` | string | Yes | The ID of the blog post to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `deleted` | boolean | Deletion status |
| `blogPostId` | string | Deleted blog post ID |
### `confluence_list_blogposts_in_space`
@@ -634,13 +743,13 @@ List all blog posts within a specific Confluence space.
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `blogPosts` | array | Array of blog posts in the space |
| ↳ `id` | string | Blog post ID |
| ↳ `id` | string | Unique blog post identifier |
| ↳ `title` | string | Blog post title |
| ↳ `status` | string | Blog post status |
| ↳ `spaceId` | string | Space ID |
| ↳ `authorId` | string | Author account ID |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `version` | object | Version information |
| ↳ `status` | string | Blog post status \(e.g., current, draft\) |
| ↳ `spaceId` | string | ID of the space containing the blog post |
| ↳ `authorId` | string | Account ID of the blog post author |
| ↳ `createdAt` | string | ISO 8601 timestamp when the blog post was created |
| ↳ `version` | object | Blog post version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
@@ -656,7 +765,7 @@ List all blog posts within a specific Confluence space.
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
| ↳ `webUrl` | string | URL to view the blog post |
| ↳ `webUrl` | string | URL to view the blog post in Confluence |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_create_comment`
@@ -676,7 +785,7 @@ Add a comment to a Confluence page.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of creation |
| `ts` | string | ISO 8601 timestamp of the operation |
| `commentId` | string | Created comment ID |
| `pageId` | string | Page ID |
@@ -737,9 +846,9 @@ Update an existing comment on a Confluence page.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of update |
| `commentId` | string | Updated comment ID |
| `ts` | string | ISO 8601 timestamp of the operation |
| `updated` | boolean | Update status |
| `commentId` | string | Updated comment ID |
### `confluence_delete_comment`
@@ -757,9 +866,9 @@ Delete a comment from a Confluence page.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of deletion |
| `commentId` | string | Deleted comment ID |
| `ts` | string | ISO 8601 timestamp of the operation |
| `deleted` | boolean | Deletion status |
| `commentId` | string | Deleted comment ID |
### `confluence_upload_attachment`
@@ -780,7 +889,7 @@ Upload a file as an attachment to a Confluence page.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of upload |
| `ts` | string | ISO 8601 timestamp of the operation |
| `attachmentId` | string | Uploaded attachment ID |
| `title` | string | Attachment file name |
| `fileSize` | number | File size in bytes |
@@ -842,9 +951,9 @@ Delete an attachment from a Confluence page (moves to trash).
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of deletion |
| `attachmentId` | string | Deleted attachment ID |
| `ts` | string | ISO 8601 timestamp of the operation |
| `deleted` | boolean | Deletion status |
| `attachmentId` | string | Deleted attachment ID |
### `confluence_list_labels`
@@ -864,7 +973,7 @@ List all labels on a Confluence page.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of retrieval |
| `ts` | string | ISO 8601 timestamp of the operation |
| `labels` | array | Array of labels on the page |
| ↳ `id` | string | Unique label identifier |
| ↳ `name` | string | Label name |

View File

@@ -30,7 +30,7 @@ With Sims Jira Service Management integration, you can create, monitor, and u
## Usage Instructions
Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues.
Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues. Can also trigger workflows based on Jira Service Management webhook events.
@@ -66,6 +66,31 @@ Get all service desks from Jira Service Management
| `total` | number | Total number of service desks |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_get_service_desk`
Get a specific service desk by ID in Jira Service Management
#### 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 |
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Service desk ID |
| `projectId` | string | Associated Jira project ID |
| `projectName` | string | Associated project name |
| `projectKey` | string | Associated project key |
| `name` | string | Service desk name |
| `description` | string | Service desk description |
| `leadDisplayName` | string | Project lead display name |
| `ts` | string | Timestamp of the operation |
### `jsm_get_request_types`
Get request types for a service desk in Jira Service Management
@@ -101,6 +126,39 @@ Get request types for a service desk in Jira Service Management
| `total` | number | Total number of request types |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_get_request_type_fields`
Get the fields required to create a request of a specific type in Jira Service Management
#### 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 |
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
| `requestTypeId` | string | Yes | Request Type ID \(e.g., "10", "15"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `serviceDeskId` | string | Service desk ID |
| `requestTypeId` | string | Request type ID |
| `canAddRequestParticipants` | boolean | Whether participants can be added to requests of this type |
| `canRaiseOnBehalfOf` | boolean | Whether requests can be raised on behalf of another user |
| `requestTypeFields` | array | List of fields for this request type |
| ↳ `fieldId` | string | Field identifier \(e.g., summary, description, customfield_10010\) |
| ↳ `name` | string | Human-readable field name |
| ↳ `description` | string | Help text for the field |
| ↳ `required` | boolean | Whether the field is required |
| ↳ `visible` | boolean | Whether the field is visible |
| ↳ `validValues` | json | Allowed values for select fields |
| ↳ `presetValues` | json | Pre-populated values |
| ↳ `defaultValues` | json | Default values for the field |
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
### `jsm_create_request`
Create a new service request in Jira Service Management
@@ -222,6 +280,59 @@ Get multiple service requests from Jira Service Management
| `total` | number | Total number of requests in current page |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_get_request_status`
Get status history for a service request in Jira Service Management
#### 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\) |
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `statuses` | array | Status history entries |
| ↳ `status` | string | Status name |
| ↳ `statusCategory` | string | Status category \(NEW, INDETERMINATE, DONE\) |
| ↳ `statusDate` | json | Status change date with iso8601, friendly, epochMillis |
| `total` | number | Total number of status entries |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_get_request_attachments`
Get attachments for a service request in Jira Service Management
#### 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\) |
| `includeAttachments` | boolean | No | Download attachment file contents and include them as files in the output |
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `attachments` | array | List of attachments |
| `total` | number | Total number of attachments |
| `isLastPage` | boolean | Whether this is the last page |
| `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) |
### `jsm_add_comment`
Add a comment (public or internal) to a service request in Jira Service Management
@@ -341,6 +452,53 @@ Add customers to a service desk in Jira Service Management
| `serviceDeskId` | string | Service desk ID |
| `success` | boolean | Whether customers were added successfully |
### `jsm_remove_customer`
Remove customers from a service desk in Jira Service Management
#### 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 |
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
| `accountIds` | string | No | Comma-separated Atlassian account IDs to remove |
| `emails` | string | No | Comma-separated email addresses to remove |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `serviceDeskId` | string | Service desk ID |
| `success` | boolean | Whether customers were removed successfully |
### `jsm_create_customer`
Create a new customer in Jira Service Management
#### 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 |
| `email` | string | Yes | Email address for the new customer |
| `displayName` | string | Yes | Display name for the new customer |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `accountId` | string | Account ID of the created customer |
| `displayName` | string | Display name of the created customer |
| `emailAddress` | string | Email address of the created customer |
| `active` | boolean | Whether the customer account is active |
| `timeZone` | string | Customer timezone |
| `success` | boolean | Whether the customer was created successfully |
### `jsm_get_organizations`
Get organizations for a service desk in Jira Service Management
@@ -366,6 +524,26 @@ Get organizations for a service desk in Jira Service Management
| `total` | number | Total number of organizations |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_get_organization`
Get a specific organization by ID in Jira Service Management
#### 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 |
| `organizationId` | string | Yes | Organization ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Organization ID |
| `name` | string | Organization name |
| `ts` | string | Timestamp of the operation |
### `jsm_create_organization`
Create a new organization in Jira Service Management
@@ -409,6 +587,119 @@ Add an organization to a service desk in Jira Service Management
| `organizationId` | string | Organization ID added |
| `success` | boolean | Whether the operation succeeded |
### `jsm_remove_organization`
Remove an organization from a service desk in Jira Service Management
#### 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 |
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
| `organizationId` | string | Yes | Organization ID to remove from the service desk |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `serviceDeskId` | string | Service Desk ID |
| `organizationId` | string | Organization ID removed |
| `success` | boolean | Whether the operation succeeded |
### `jsm_delete_organization`
Delete an organization in Jira Service Management
#### 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 |
| `organizationId` | string | Yes | Organization ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `organizationId` | string | ID of the deleted organization |
| `success` | boolean | Whether the organization was deleted |
### `jsm_get_organization_users`
Get users in an organization in Jira Service Management
#### 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 |
| `organizationId` | string | Yes | Organization ID to get users from |
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `organizationId` | string | Organization ID |
| `users` | array | List of users in the organization |
| ↳ `accountId` | string | Atlassian account ID |
| ↳ `displayName` | string | Display name |
| ↳ `emailAddress` | string | Email address |
| ↳ `active` | boolean | Whether the account is active |
| ↳ `timeZone` | string | User timezone |
| `total` | number | Total number of users |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_add_organization_users`
Add users to an organization in Jira Service Management
#### 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 |
| `organizationId` | string | Yes | Organization ID to add users to |
| `accountIds` | string | Yes | Comma-separated account IDs to add to the organization |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `organizationId` | string | Organization ID |
| `success` | boolean | Whether users were added successfully |
### `jsm_remove_organization_users`
Remove users from an organization in Jira Service Management
#### 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 |
| `organizationId` | string | Yes | Organization ID to remove users from |
| `accountIds` | string | Yes | Comma-separated account IDs to remove from the organization |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `organizationId` | string | Organization ID |
| `success` | boolean | Whether users were removed successfully |
### `jsm_get_queues`
Get queues for a service desk in Jira Service Management
@@ -438,6 +729,51 @@ Get queues for a service desk in Jira Service Management
| `total` | number | Total number of queues |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_get_queue_issues`
Get issues in a specific queue for a service desk in Jira Service Management
#### 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 |
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
| `queueId` | string | Yes | Queue ID to get issues from |
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `serviceDeskId` | string | Service desk ID |
| `queueId` | string | Queue ID |
| `issues` | array | List of issues in the queue |
| ↳ `issueId` | string | Jira issue ID |
| ↳ `issueKey` | string | Issue key \(e.g., SD-123\) |
| ↳ `requestTypeId` | string | Request type ID |
| ↳ `serviceDeskId` | string | Service desk ID |
| ↳ `createdDate` | json | Creation date with iso8601, friendly, epochMillis |
| ↳ `currentStatus` | object | Current request status |
| ↳ `status` | string | Status name |
| ↳ `statusCategory` | string | Status category \(NEW, INDETERMINATE, DONE\) |
| ↳ `statusDate` | json | Status change date with iso8601, friendly, epochMillis |
| ↳ `reporter` | object | Reporter user details |
| ↳ `accountId` | string | Atlassian account ID |
| ↳ `displayName` | string | User display name |
| ↳ `emailAddress` | string | User email address |
| ↳ `active` | boolean | Whether the account is active |
| ↳ `requestFieldValues` | array | Request field values |
| ↳ `fieldId` | string | Field identifier |
| ↳ `label` | string | Human-readable field label |
| ↳ `value` | json | Field value |
| ↳ `renderedValue` | json | HTML-rendered field value |
| `total` | number | Total number of issues in the queue |
| `isLastPage` | boolean | Whether this is the last page |
### `jsm_get_sla`
Get SLA information for a service request in Jira Service Management
@@ -569,6 +905,32 @@ Add participants to a request in Jira Service Management
| ↳ `active` | boolean | Whether the account is active |
| `success` | boolean | Whether the operation succeeded |
### `jsm_remove_participants`
Remove participants from a request in Jira Service Management
#### 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\) |
| `accountIds` | string | Yes | Comma-separated account IDs to remove as participants |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `participants` | array | Remaining participants after removal |
| ↳ `accountId` | string | Atlassian account ID |
| ↳ `displayName` | string | Display name |
| ↳ `emailAddress` | string | Email address |
| ↳ `active` | boolean | Whether the account is active |
| `success` | boolean | Whether the operation succeeded |
### `jsm_get_approvals`
Get approvals for a request in Jira Service Management
@@ -644,9 +1006,9 @@ Approve or decline an approval request in Jira Service Management
| `approval` | json | The approval object |
| `success` | boolean | Whether the operation succeeded |
### `jsm_get_request_type_fields`
### `jsm_get_feedback`
Get the fields required to create a request of a specific type in Jira Service Management
Get CSAT feedback for a service request in Jira Service Management
#### Input
@@ -654,27 +1016,152 @@ Get the fields required to create a request of a specific type in Jira Service M
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
| `requestTypeId` | string | Yes | Request Type ID \(e.g., "10", "15"\) |
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `serviceDeskId` | string | Service desk ID |
| `requestTypeId` | string | Request type ID |
| `canAddRequestParticipants` | boolean | Whether participants can be added to requests of this type |
| `canRaiseOnBehalfOf` | boolean | Whether requests can be raised on behalf of another user |
| `requestTypeFields` | array | List of fields for this request type |
| ↳ `fieldId` | string | Field identifier \(e.g., summary, description, customfield_10010\) |
| ↳ `name` | string | Human-readable field name |
| ↳ `description` | string | Help text for the field |
| ↳ `required` | boolean | Whether the field is required |
| ↳ `visible` | boolean | Whether the field is visible |
| ↳ `validValues` | json | Allowed values for select fields |
| ↳ `presetValues` | json | Pre-populated values |
| ↳ `defaultValues` | json | Default values for the field |
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
| `issueIdOrKey` | string | Issue ID or key |
| `rating` | number | CSAT rating \(1-5\) |
| `comment` | string | Feedback comment |
| `type` | string | Feedback type \(e.g., csat\) |
### `jsm_add_feedback`
Add CSAT feedback to a service request in Jira Service Management
#### 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\) |
| `rating` | number | Yes | CSAT rating \(1-5\) |
| `comment` | string | No | Optional feedback comment |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `rating` | number | CSAT rating submitted |
| `comment` | string | Feedback comment |
| `type` | string | Feedback type |
| `success` | boolean | Whether feedback was submitted successfully |
### `jsm_delete_feedback`
Delete CSAT feedback from a service request in Jira Service Management
#### 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\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `success` | boolean | Whether feedback was deleted |
### `jsm_get_notification`
Get notification subscription status for a request in Jira Service Management
#### 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\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `subscribed` | boolean | Whether currently subscribed to notifications |
### `jsm_subscribe_notification`
Subscribe to notifications for a request in Jira Service Management
#### 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\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `success` | boolean | Whether subscription was successful |
### `jsm_unsubscribe_notification`
Unsubscribe from notifications for a request in Jira Service Management
#### 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\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `success` | boolean | Whether unsubscription was successful |
### `jsm_search_knowledge_base`
Search knowledge base articles in Jira Service Management
#### 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 |
| `serviceDeskId` | string | No | Service Desk ID to search within \(optional, searches globally if omitted\) |
| `query` | string | Yes | Search query for knowledge base articles |
| `highlight` | boolean | No | Whether to highlight matching text in results |
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `articles` | array | List of knowledge base articles |
| ↳ `title` | string | Article title |
| ↳ `excerpt` | string | Article excerpt/summary |
| ↳ `sourceType` | string | Source type \(e.g., confluence\) |
| ↳ `sourcePageId` | string | Source page ID |
| ↳ `sourceSpaceKey` | string | Source space key |
| ↳ `contentUrl` | string | URL to rendered content |
| `total` | number | Total number of articles found |
| `isLastPage` | boolean | Whether this is the last page |

View File

@@ -1,81 +1,145 @@
import { db } from '@sim/db'
import { settings } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
import { getSession } from '@/lib/auth'
const logger = createLogger('CopilotAutoAllowedToolsAPI')
function copilotHeaders(): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
return headers
}
export async function DELETE(request: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const toolIdFromQuery = new URL(request.url).searchParams.get('toolId') || undefined
const toolIdFromBody = await request
.json()
.then((body) => (typeof body?.toolId === 'string' ? body.toolId : undefined))
.catch(() => undefined)
const toolId = toolIdFromBody || toolIdFromQuery
if (!toolId) {
return NextResponse.json({ error: 'toolId is required' }, { status: 400 })
}
/**
* GET - Fetch user's auto-allowed integration tools
*/
export async function GET() {
try {
const res = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, {
method: 'DELETE',
headers: copilotHeaders(),
body: JSON.stringify({
userId,
toolId,
}),
})
const session = await getSession()
const payload = await res.json().catch(() => ({}))
if (!res.ok) {
logger.warn('Failed to remove auto-allowed tool via copilot backend', {
status: res.status,
userId,
toolId,
})
return NextResponse.json(
{
success: false,
error: payload?.error || 'Failed to remove auto-allowed tool',
autoAllowedTools: [],
},
{ status: res.status }
)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.json({
success: true,
autoAllowedTools: Array.isArray(payload?.autoAllowedTools) ? payload.autoAllowedTools : [],
})
} catch (error) {
logger.error('Error removing auto-allowed tool', {
const userId = session.user.id
const [userSettings] = await db
.select()
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
if (userSettings) {
const autoAllowedTools = (userSettings.copilotAutoAllowedTools as string[]) || []
return NextResponse.json({ autoAllowedTools })
}
await db.insert(settings).values({
id: userId,
userId,
toolId,
error: error instanceof Error ? error.message : String(error),
copilotAutoAllowedTools: [],
})
return NextResponse.json(
{
success: false,
error: 'Failed to remove auto-allowed tool',
autoAllowedTools: [],
},
{ status: 500 }
)
return NextResponse.json({ autoAllowedTools: [] })
} catch (error) {
logger.error('Failed to fetch auto-allowed tools', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Add a tool to the auto-allowed list
*/
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const body = await request.json()
if (!body.toolId || typeof body.toolId !== 'string') {
return NextResponse.json({ error: 'toolId must be a string' }, { status: 400 })
}
const toolId = body.toolId
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (existing) {
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
if (!currentTools.includes(toolId)) {
const updatedTools = [...currentTools, toolId]
await db
.update(settings)
.set({
copilotAutoAllowedTools: updatedTools,
updatedAt: new Date(),
})
.where(eq(settings.userId, userId))
logger.info('Added tool to auto-allowed list', { userId, toolId })
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
}
return NextResponse.json({ success: true, autoAllowedTools: currentTools })
}
await db.insert(settings).values({
id: userId,
userId,
copilotAutoAllowedTools: [toolId],
})
logger.info('Created settings and added tool to auto-allowed list', { userId, toolId })
return NextResponse.json({ success: true, autoAllowedTools: [toolId] })
} catch (error) {
logger.error('Failed to add auto-allowed tool', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE - Remove a tool from the auto-allowed list
*/
export async function DELETE(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const { searchParams } = new URL(request.url)
const toolId = searchParams.get('toolId')
if (!toolId) {
return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 })
}
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (existing) {
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
const updatedTools = currentTools.filter((t) => t !== toolId)
await db
.update(settings)
.set({
copilotAutoAllowedTools: updatedTools,
updatedAt: new Date(),
})
.where(eq(settings.userId, userId))
logger.info('Removed tool from auto-allowed list', { userId, toolId })
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
}
return NextResponse.json({ success: true, autoAllowedTools: [] })
} catch (error) {
logger.error('Failed to remove auto-allowed tool', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,11 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
REDIS_TOOL_CALL_PREFIX,
REDIS_TOOL_CALL_TTL_SECONDS,
SIM_AGENT_API_URL,
} from '@/lib/copilot/constants'
import { REDIS_TOOL_CALL_PREFIX, REDIS_TOOL_CALL_TTL_SECONDS } from '@/lib/copilot/constants'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -14,7 +10,6 @@ import {
createUnauthorizedResponse,
type NotificationStatus,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('CopilotConfirmAPI')
@@ -26,8 +21,6 @@ const ConfirmationSchema = z.object({
errorMap: () => ({ message: 'Invalid notification status' }),
}),
message: z.string().optional(), // Optional message for background moves or additional context
toolName: z.string().optional(),
remember: z.boolean().optional(),
})
/**
@@ -64,44 +57,6 @@ async function updateToolCallStatus(
}
}
async function saveAutoAllowedToolPreference(userId: string, toolName: string): Promise<boolean> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
try {
const response = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, {
method: 'POST',
headers,
body: JSON.stringify({
userId,
toolId: toolName,
}),
})
if (!response.ok) {
logger.warn('Failed to persist auto-allowed tool preference', {
userId,
toolName,
status: response.status,
})
return false
}
return true
} catch (error) {
logger.error('Error persisting auto-allowed tool preference', {
userId,
toolName,
error: error instanceof Error ? error.message : String(error),
})
return false
}
}
/**
* POST /api/copilot/confirm
* Update tool call status (Accept/Reject)
@@ -119,7 +74,7 @@ export async function POST(req: NextRequest) {
}
const body = await req.json()
const { toolCallId, status, message, toolName, remember } = ConfirmationSchema.parse(body)
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
// Update the tool call status in Redis
const updated = await updateToolCallStatus(toolCallId, status, message)
@@ -135,22 +90,14 @@ export async function POST(req: NextRequest) {
return createBadRequestResponse('Failed to update tool call status or tool call not found')
}
let rememberSaved = false
if (status === 'accepted' && remember === true && toolName && authenticatedUserId) {
rememberSaved = await saveAutoAllowedToolPreference(authenticatedUserId, toolName)
}
const duration = tracker.getDuration()
const response: Record<string, unknown> = {
return NextResponse.json({
success: true,
message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`,
toolCallId,
status,
}
if (remember === true) {
response.rememberSaved = rememberSaved
}
return NextResponse.json(response)
})
} catch (error) {
const duration = tracker.getDuration()

View File

@@ -38,6 +38,45 @@ const createBlogPostSchema = z.object({
status: z.enum(['current', 'draft']).optional(),
})
const updateBlogPostSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
blogPostId: z.string().min(1, 'Blog post ID is required'),
title: z.string().optional(),
content: z.string().optional(),
status: z.enum(['current', 'draft']).optional(),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
}
)
const deleteBlogPostSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
blogPostId: z.string().min(1, 'Blog post ID is required'),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
}
)
/**
* List all blog posts or get a specific blog post
*/
@@ -283,3 +322,174 @@ export async function POST(request: NextRequest) {
)
}
}
/**
* Update a blog post
*/
export async function PUT(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = updateBlogPostSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const {
domain,
accessToken,
cloudId: providedCloudId,
blogPostId,
title,
content,
status,
} = validation.data
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const blogPostUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
const currentResponse = await fetch(blogPostUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!currentResponse.ok) {
const errorData = await currentResponse.json().catch(() => null)
const errorMessage =
errorData?.message || `Failed to fetch blog post for update (${currentResponse.status})`
return NextResponse.json({ error: errorMessage }, { status: currentResponse.status })
}
const currentPost = await currentResponse.json()
const currentVersion = currentPost.version.number
const updateBody: Record<string, unknown> = {
id: blogPostId,
version: {
number: currentVersion + 1,
message: 'Updated via Sim',
},
status: status || currentPost.status || 'current',
title: title || currentPost.title,
}
if (content) {
updateBody.body = {
representation: 'storage',
value: content,
}
}
const response = await fetch(blogPostUrl, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to update blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
id: data.id,
title: data.title,
status: data.status ?? null,
spaceId: data.spaceId ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
version: data.version ?? null,
body: data.body ?? null,
webUrl: data._links?.webui ?? null,
})
} catch (error) {
logger.error('Error updating blog post:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Delete a blog post
*/
export async function DELETE(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = deleteBlogPostSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { domain, accessToken, cloudId: providedCloudId, blogPostId } = validation.data
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to delete blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ blogPostId, deleted: true })
} catch (error) {
logger.error('Error deleting blog post:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,152 @@
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 {
downloadJsmAttachments,
getJiraCloudId,
getJsmApiBaseUrl,
getJsmHeaders,
} from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmAttachmentsAPI')
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,
includeAttachments,
start,
limit,
} = 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
if (!issueIdOrKeyValidation.isValid) {
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { 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 baseUrl = getJsmApiBaseUrl(cloudId)
const params = new URLSearchParams()
if (start) params.append('start', start)
if (limit) params.append('limit', limit)
const url = `${baseUrl}/request/${issueIdOrKey}/attachment${params.toString() ? `?${params.toString()}` : ''}`
logger.info('Fetching request attachments from:', url)
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
const rawAttachments = data.values || []
const attachments = rawAttachments.map((att: Record<string, unknown>) => ({
filename: att.filename ?? '',
author: att.author
? {
accountId: (att.author as Record<string, unknown>).accountId ?? '',
displayName: (att.author as Record<string, unknown>).displayName ?? '',
active: (att.author as Record<string, unknown>).active ?? true,
}
: null,
created: att.created ?? null,
size: att.size ?? 0,
mimeType: att.mimeType ?? '',
}))
let files: Array<{ name: string; mimeType: string; data: string; size: number }> | undefined
if (includeAttachments && rawAttachments.length > 0) {
const downloadable = rawAttachments
.filter((att: Record<string, unknown>) => {
const links = att._links as Record<string, string> | undefined
return links?.content
})
.map((att: Record<string, unknown>) => ({
contentUrl: (att._links as Record<string, string>).content as string,
filename: (att.filename as string) ?? '',
mimeType: (att.mimeType as string) ?? '',
size: (att.size as number) ?? 0,
}))
if (downloadable.length > 0) {
files = await downloadJsmAttachments(downloadable, accessToken)
}
}
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
attachments,
total: data.size || 0,
isLastPage: data.isLastPage ?? true,
...(files && files.length > 0 ? { files } : {}),
},
})
} catch (error) {
logger.error('Error fetching attachments:', {
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 }
)
}
}

View File

@@ -0,0 +1,101 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmCustomerAPI')
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, email, displayName } = 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 (!email) {
logger.error('Missing email in request')
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
}
if (!displayName) {
logger.error('Missing displayName in request')
return NextResponse.json({ error: 'Display name 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 baseUrl = getJsmApiBaseUrl(cloudId)
const url = `${baseUrl}/customer`
logger.info('Creating customer:', { email, displayName })
const response = await fetch(url, {
method: 'POST',
headers: getJsmHeaders(accessToken),
body: JSON.stringify({ email, displayName }),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
accountId: data.accountId ?? '',
displayName: data.displayName ?? '',
emailAddress: data.emailAddress ?? '',
active: data.active ?? true,
timeZone: data.timeZone ?? null,
success: true,
},
})
} catch (error) {
logger.error('Error creating customer:', {
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 }
)
}
}

View File

@@ -57,6 +57,8 @@ export async function POST(request: NextRequest) {
const baseUrl = getJsmApiBaseUrl(cloudId)
const { action: customerAction } = body
const rawIds = accountIds || emails
const parsedAccountIds = rawIds
? typeof rawIds === 'string'
@@ -69,7 +71,50 @@ export async function POST(request: NextRequest) {
: []
: []
const isAddOperation = parsedAccountIds.length > 0
const isRemoveOperation = customerAction === 'remove'
const isAddOperation = !isRemoveOperation && parsedAccountIds.length > 0
if (isRemoveOperation) {
if (parsedAccountIds.length === 0) {
return NextResponse.json(
{ error: 'Account IDs or emails are required for removal' },
{ status: 400 }
)
}
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`
logger.info('Removing customers from:', url, { accountIds: parsedAccountIds })
const response = await fetch(url, {
method: 'DELETE',
headers: getJsmHeaders(accessToken),
body: JSON.stringify({ accountIds: parsedAccountIds }),
})
if (response.status === 204 || response.ok) {
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
serviceDeskId,
success: true,
},
})
}
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
if (isAddOperation) {
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`

View File

@@ -0,0 +1,219 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
validateEnum,
validateJiraCloudId,
validateJiraIssueKey,
} from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmFeedbackAPI')
const VALID_ACTIONS = ['get', 'add', 'delete'] as const
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,
action,
issueIdOrKey,
rating,
comment,
} = 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 })
}
if (!action) {
logger.error('Missing action in request')
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
}
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
if (!actionValidation.isValid) {
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
}
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
if (!issueIdOrKeyValidation.isValid) {
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { 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 baseUrl = getJsmApiBaseUrl(cloudId)
const url = `${baseUrl}/request/${issueIdOrKey}/feedback`
if (action === 'get') {
logger.info('Fetching feedback from:', url)
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
rating: data.rating ?? null,
comment: data.comment?.body ?? null,
type: data.type ?? null,
},
})
}
if (action === 'add') {
if (rating === undefined || rating === null) {
logger.error('Missing rating in request')
return NextResponse.json({ error: 'Rating is required (1-5)' }, { status: 400 })
}
logger.info('Adding feedback to:', url, { rating })
const feedbackBody: Record<string, unknown> = {
rating: Number(rating),
type: 'csat',
}
if (comment) {
feedbackBody.comment = { body: comment }
}
const response = await fetch(url, {
method: 'POST',
headers: getJsmHeaders(accessToken),
body: JSON.stringify(feedbackBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
rating: data.rating ?? Number(rating),
comment: data.comment?.body ?? comment ?? null,
type: data.type ?? 'csat',
success: true,
},
})
}
if (action === 'delete') {
logger.info('Deleting feedback from:', url)
const response = await fetch(url, {
method: 'DELETE',
headers: getJsmHeaders(accessToken),
})
if (response.status === 204 || response.ok) {
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
success: true,
},
})
}
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error('Error in feedback operation:', {
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 }
)
}
}

View File

@@ -0,0 +1,127 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmKnowledgeBaseAPI')
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,
serviceDeskId,
query,
highlight,
start,
limit,
} = 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 (!query) {
logger.error('Missing query in request')
return NextResponse.json({ error: 'Search query is required' }, { status: 400 })
}
if (serviceDeskId) {
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
if (!serviceDeskIdValidation.isValid) {
return NextResponse.json({ error: serviceDeskIdValidation.error }, { 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 baseUrl = getJsmApiBaseUrl(cloudId)
const params = new URLSearchParams()
params.append('query', query)
if (highlight !== undefined) params.append('highlight', String(highlight))
if (start) params.append('start', start)
if (limit) params.append('limit', limit)
const basePath = serviceDeskId
? `${baseUrl}/servicedesk/${serviceDeskId}/knowledgebase/article`
: `${baseUrl}/knowledgebase/article`
const url = `${basePath}?${params.toString()}`
logger.info('Searching knowledge base:', url)
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
const articles = (data.values || []).map((article: Record<string, unknown>) => ({
title: (article.title as string) ?? '',
excerpt: (article.excerpt as string) ?? '',
sourceType: (article.source as Record<string, unknown>)?.type ?? '',
sourcePageId: (article.source as Record<string, unknown>)?.pageId ?? null,
sourceSpaceKey: (article.source as Record<string, unknown>)?.spaceKey ?? null,
contentUrl: (article.content as Record<string, unknown>)?.iframeSrc ?? null,
}))
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
articles,
total: data.size || 0,
isLastPage: data.isLastPage ?? true,
},
})
} catch (error) {
logger.error('Error searching knowledge base:', {
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 }
)
}
}

View File

@@ -0,0 +1,189 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
validateEnum,
validateJiraCloudId,
validateJiraIssueKey,
} from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmNotificationAPI')
const VALID_ACTIONS = ['get', 'subscribe', 'unsubscribe'] as const
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, action, 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 })
}
if (!action) {
logger.error('Missing action in request')
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
}
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
if (!actionValidation.isValid) {
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
}
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
if (!issueIdOrKeyValidation.isValid) {
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { 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 baseUrl = getJsmApiBaseUrl(cloudId)
const url = `${baseUrl}/request/${issueIdOrKey}/notification`
if (action === 'get') {
logger.info('Fetching notification status from:', url)
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
subscribed: data.subscribed ?? false,
},
})
}
if (action === 'subscribe') {
logger.info('Subscribing to notifications:', url)
const response = await fetch(url, {
method: 'PUT',
headers: getJsmHeaders(accessToken),
})
if (response.status === 204 || response.ok) {
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
success: true,
},
})
}
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
if (action === 'unsubscribe') {
logger.info('Unsubscribing from notifications:', url)
const response = await fetch(url, {
method: 'DELETE',
headers: getJsmHeaders(accessToken),
})
if (response.status === 204 || response.ok) {
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
success: true,
},
})
}
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error('Error in notification operation:', {
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 }
)
}
}

View File

@@ -12,7 +12,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmOrganizationAPI')
const VALID_ACTIONS = ['create', 'add_to_service_desk'] as const
const VALID_ACTIONS = [
'create',
'add_to_service_desk',
'remove_from_service_desk',
'delete',
'get',
] as const
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
@@ -159,6 +165,152 @@ export async function POST(request: NextRequest) {
)
}
if (action === 'remove_from_service_desk') {
if (!serviceDeskId) {
logger.error('Missing serviceDeskId in request')
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
}
if (!organizationId) {
logger.error('Missing organizationId in request')
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
}
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
if (!serviceDeskIdValidation.isValid) {
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
}
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
if (!organizationIdValidation.isValid) {
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
}
const url = `${baseUrl}/servicedesk/${serviceDeskId}/organization`
logger.info('Removing organization from service desk:', { serviceDeskId, organizationId })
const response = await fetch(url, {
method: 'DELETE',
headers: getJsmHeaders(accessToken),
body: JSON.stringify({ organizationId: Number.parseInt(organizationId, 10) }),
})
if (response.status === 204 || response.ok) {
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
serviceDeskId,
organizationId,
success: true,
},
})
}
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
if (action === 'delete') {
if (!organizationId) {
logger.error('Missing organizationId in request')
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
}
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
if (!organizationIdValidation.isValid) {
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
}
const url = `${baseUrl}/organization/${organizationId}`
logger.info('Deleting organization:', { organizationId })
const response = await fetch(url, {
method: 'DELETE',
headers: getJsmHeaders(accessToken),
})
if (response.status === 204 || response.ok) {
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
organizationId,
success: true,
},
})
}
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
if (action === 'get') {
if (!organizationId) {
logger.error('Missing organizationId in request')
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
}
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
if (!organizationIdValidation.isValid) {
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
}
const url = `${baseUrl}/organization/${organizationId}`
logger.info('Fetching organization:', { organizationId })
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
name: data.name ?? '',
},
})
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error('Error in organization operation:', {

View File

@@ -0,0 +1,190 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateEnum,
validateJiraCloudId,
} from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmOrganizationUsersAPI')
const VALID_ACTIONS = ['get', 'add', 'remove'] as const
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,
action,
organizationId,
accountIds,
start,
limit,
} = 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 (!organizationId) {
logger.error('Missing organizationId in request')
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
}
if (!action) {
logger.error('Missing action in request')
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
}
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
if (!actionValidation.isValid) {
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
}
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
if (!organizationIdValidation.isValid) {
return NextResponse.json({ error: organizationIdValidation.error }, { 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 baseUrl = getJsmApiBaseUrl(cloudId)
const url = `${baseUrl}/organization/${organizationId}/user`
if (action === 'get') {
const params = new URLSearchParams()
if (start) params.append('start', start)
if (limit) params.append('limit', limit)
const getUrl = `${url}${params.toString() ? `?${params.toString()}` : ''}`
logger.info('Fetching organization users from:', getUrl)
const response = await fetch(getUrl, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
organizationId,
users: data.values || [],
total: data.size || 0,
isLastPage: data.isLastPage ?? true,
},
})
}
if (action === 'add' || action === 'remove') {
if (!accountIds) {
logger.error('Missing accountIds in request')
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
}
const parsedAccountIds =
typeof accountIds === 'string'
? accountIds
.split(',')
.map((id: string) => id.trim())
.filter((id: string) => id)
: accountIds
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} organization users:`, {
organizationId,
accountIds: parsedAccountIds,
})
const method = action === 'add' ? 'POST' : 'DELETE'
const response = await fetch(url, {
method,
headers: getJsmHeaders(accessToken),
body: JSON.stringify({ accountIds: parsedAccountIds }),
})
if (response.status === 204 || response.ok) {
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
organizationId,
success: true,
},
})
}
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: `JSM API error: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
)
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error('Error in organization users operation:', {
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 }
)
}
}

View File

@@ -12,7 +12,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmParticipantsAPI')
const VALID_ACTIONS = ['get', 'add'] as const
const VALID_ACTIONS = ['get', 'add', 'remove'] as const
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
},
})
}
if (action === 'add') {
if (action === 'add' || action === 'remove') {
if (!accountIds) {
logger.error('Missing accountIds in request')
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
@@ -128,16 +128,19 @@ export async function POST(request: NextRequest) {
: accountIds
const url = `${baseUrl}/request/${issueIdOrKey}/participant`
const method = action === 'add' ? 'POST' : 'DELETE'
logger.info('Adding participants to:', url, { accountIds: parsedAccountIds })
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} participants:`, url, {
accountIds: parsedAccountIds,
})
const response = await fetch(url, {
method: 'POST',
method,
headers: getJsmHeaders(accessToken),
body: JSON.stringify({ accountIds: parsedAccountIds }),
})
if (!response.ok) {
if (!response.ok && response.status !== 204) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
@@ -151,14 +154,22 @@ export async function POST(request: NextRequest) {
)
}
const data = await response.json()
let participants: unknown[] = []
if (response.status !== 204) {
try {
const data = await response.json()
participants = data.values || []
} catch {
// DELETE may return empty body
}
}
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
participants: data.values || [],
participants,
success: true,
},
})

View File

@@ -0,0 +1,121 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmQueueIssuesAPI')
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,
serviceDeskId,
queueId,
start,
limit,
} = 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 (!serviceDeskId) {
logger.error('Missing serviceDeskId in request')
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
}
if (!queueId) {
logger.error('Missing queueId in request')
return NextResponse.json({ error: 'Queue ID is required' }, { status: 400 })
}
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
if (!serviceDeskIdValidation.isValid) {
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
}
const queueIdValidation = validateAlphanumericId(queueId, 'queueId')
if (!queueIdValidation.isValid) {
return NextResponse.json({ error: queueIdValidation.error }, { 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 baseUrl = getJsmApiBaseUrl(cloudId)
const params = new URLSearchParams()
if (start) params.append('start', start)
if (limit) params.append('limit', limit)
const url = `${baseUrl}/servicedesk/${serviceDeskId}/queue/${queueId}/issue${params.toString() ? `?${params.toString()}` : ''}`
logger.info('Fetching queue issues from:', url)
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
serviceDeskId,
queueId,
issues: data.values || [],
total: data.size || 0,
isLastPage: data.isLastPage ?? true,
},
})
} catch (error) {
logger.error('Error fetching queue issues:', {
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 }
)
}
}

View File

@@ -0,0 +1,102 @@
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, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestStatusAPI')
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, start, limit } = 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
if (!issueIdOrKeyValidation.isValid) {
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { 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 baseUrl = getJsmApiBaseUrl(cloudId)
const params = new URLSearchParams()
if (start) params.append('start', start)
if (limit) params.append('limit', limit)
const url = `${baseUrl}/request/${issueIdOrKey}/status${params.toString() ? `?${params.toString()}` : ''}`
logger.info('Fetching request status history from:', url)
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
statuses: data.values || [],
total: data.size || 0,
isLastPage: data.isLastPage ?? true,
},
})
} catch (error) {
logger.error('Error fetching request status:', {
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 }
)
}
}

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
@@ -16,7 +16,16 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, expand, start, limit } = body
const {
domain,
accessToken,
cloudId: cloudIdParam,
expand,
start,
limit,
serviceDeskId,
action,
} = body
if (!domain) {
logger.error('Missing domain in request')
@@ -37,6 +46,52 @@ export async function POST(request: NextRequest) {
const baseUrl = getJsmApiBaseUrl(cloudId)
if (action === 'get' && serviceDeskId) {
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
if (!serviceDeskIdValidation.isValid) {
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
}
const url = `${baseUrl}/servicedesk/${serviceDeskId}`
logger.info('Fetching service desk:', url)
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
projectId: data.projectId ?? '',
projectName: data.projectName ?? '',
projectKey: data.projectKey ?? '',
name: data.projectName ?? '',
description: data.description ?? null,
leadDisplayName: data.leadDisplayName ?? null,
},
})
}
const params = new URLSearchParams()
if (expand) params.append('expand', expand)
if (start) params.append('start', start)

View File

@@ -14,15 +14,6 @@ const logger = createLogger('DiffControls')
const NOTIFICATION_WIDTH = 240
const NOTIFICATION_GAP = 16
function isWorkflowEditToolCall(name?: string, params?: Record<string, unknown>): boolean {
if (name === 'edit_workflow') return true
if (name !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
return typeof params?.proposalId === 'string' && params.proposalId.length > 0
}
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isPanelResizing = usePanelStore((state) => state.isResizing)
@@ -73,7 +64,7 @@ export const DiffControls = memo(function DiffControls() {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (isWorkflowEditToolCall(tn, b.toolCall?.params)) {
if (tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
@@ -81,9 +72,7 @@ export const DiffControls = memo(function DiffControls() {
}
}
if (!id) {
const candidates = Object.values(toolCallsById).filter((t) =>
isWorkflowEditToolCall(t.name, t.params)
)
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('accepted', id)
@@ -113,7 +102,7 @@ export const DiffControls = memo(function DiffControls() {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (isWorkflowEditToolCall(tn, b.toolCall?.params)) {
if (tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
@@ -121,9 +110,7 @@ export const DiffControls = memo(function DiffControls() {
}
}
if (!id) {
const candidates = Object.values(toolCallsById).filter((t) =>
isWorkflowEditToolCall(t.name, t.params)
)
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('rejected', id)

View File

@@ -47,28 +47,6 @@ interface ParsedTags {
cleanContent: string
}
function getToolCallParams(toolCall?: CopilotToolCall): Record<string, unknown> {
const candidate = ((toolCall as any)?.parameters ||
(toolCall as any)?.input ||
(toolCall as any)?.params ||
{}) as Record<string, unknown>
return candidate && typeof candidate === 'object' ? candidate : {}
}
function isWorkflowChangeApplyMode(toolCall?: CopilotToolCall): boolean {
if (!toolCall || toolCall.name !== 'workflow_change') return false
const params = getToolCallParams(toolCall)
const mode = typeof params.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
return typeof params.proposalId === 'string' && params.proposalId.length > 0
}
function isWorkflowEditSummaryTool(toolCall?: CopilotToolCall): boolean {
if (!toolCall) return false
if (toolCall.name === 'edit_workflow') return true
return isWorkflowChangeApplyMode(toolCall)
}
/**
* Extracts plan steps from plan_respond tool calls in subagent blocks.
* @param blocks - The subagent content blocks to search
@@ -893,10 +871,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
)
}
if (segment.type === 'tool' && segment.block.toolCall) {
if (
(toolCall.name === 'edit' || toolCall.name === 'build') &&
isWorkflowEditSummaryTool(segment.block.toolCall)
) {
if (toolCall.name === 'edit' && segment.block.toolCall.name === 'edit_workflow') {
return (
<div key={`tool-${segment.block.toolCall.id || index}`}>
<WorkflowEditSummary toolCall={segment.block.toolCall} />
@@ -993,11 +968,12 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
}
}, [blocks])
if (!isWorkflowEditSummaryTool(toolCall)) {
if (toolCall.name !== 'edit_workflow') {
return null
}
const params = getToolCallParams(toolCall)
const params =
(toolCall as any).parameters || (toolCall as any).input || (toolCall as any).params || {}
let operations = Array.isArray(params.operations) ? params.operations : []
if (operations.length === 0 && Array.isArray((toolCall as any).operations)) {
@@ -1243,6 +1219,11 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
)
})
/** Checks if a tool is server-side executed (not a client tool) */
function isIntegrationTool(toolName: string): boolean {
return !TOOL_DISPLAY_REGISTRY[toolName]
}
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
if (!toolCall.name || toolCall.name === 'unknown_tool') {
return false
@@ -1252,96 +1233,59 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
return false
}
if (toolCall.ui?.showInterrupt !== true) {
// Never show buttons for tools the user has marked as always-allowed
if (useCopilotStore.getState().isToolAutoAllowed(toolCall.name)) {
return false
}
return true
const hasInterrupt = !!TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.interrupt
if (hasInterrupt) {
return true
}
// Integration tools (user-installed) always require approval
if (isIntegrationTool(toolCall.name)) {
return true
}
return false
}
const toolCallLogger = createLogger('CopilotToolCall')
async function sendToolDecision(
toolCallId: string,
status: 'accepted' | 'rejected' | 'background',
options?: {
toolName?: string
remember?: boolean
}
status: 'accepted' | 'rejected' | 'background'
) {
try {
await fetch('/api/copilot/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolCallId,
status,
...(options?.toolName ? { toolName: options.toolName } : {}),
...(options?.remember ? { remember: true } : {}),
}),
body: JSON.stringify({ toolCallId, status }),
})
} catch (error) {
toolCallLogger.warn('Failed to send tool decision', {
toolCallId,
status,
remember: options?.remember === true,
toolName: options?.toolName,
error: error instanceof Error ? error.message : String(error),
})
}
}
async function removeAutoAllowedToolPreference(toolName: string): Promise<boolean> {
try {
const response = await fetch(`/api/copilot/auto-allowed-tools?toolId=${encodeURIComponent(toolName)}`, {
method: 'DELETE',
})
return response.ok
} catch (error) {
toolCallLogger.warn('Failed to remove auto-allowed tool preference', {
toolName,
error: error instanceof Error ? error.message : String(error),
})
return false
}
}
type ToolUiAction = NonNullable<NonNullable<CopilotToolCall['ui']>['actions']>[number]
function actionDecision(action: ToolUiAction): 'accepted' | 'rejected' | 'background' {
const id = action.id.toLowerCase()
if (id.includes('background')) return 'background'
if (action.kind === 'reject') return 'rejected'
return 'accepted'
}
function isClientRunCapability(toolCall: CopilotToolCall): boolean {
if (toolCall.execution?.target === 'sim_client_capability') {
return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId
}
return CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)
}
async function handleRun(
toolCall: CopilotToolCall,
setToolCallState: any,
onStateChange?: any,
editedParams?: any,
options?: {
remember?: boolean
}
editedParams?: any
) {
setToolCallState(toolCall, 'executing', editedParams ? { params: editedParams } : undefined)
onStateChange?.('executing')
await sendToolDecision(toolCall.id, 'accepted', {
toolName: toolCall.name,
remember: options?.remember === true,
})
await sendToolDecision(toolCall.id, 'accepted')
// Client-executable run tools: execute on the client for real-time feedback
// (block pulsing, console logs, stop button). The server defers execution
// for these tools; the client reports back via mark-complete.
if (isClientRunCapability(toolCall)) {
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)) {
const params = editedParams || toolCall.params || {}
executeRunToolOnClient(toolCall.id, toolCall.name, params)
}
@@ -1354,9 +1298,6 @@ async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onSt
}
function getDisplayName(toolCall: CopilotToolCall): string {
if (toolCall.ui?.phaseLabel) return toolCall.ui.phaseLabel
if (toolCall.ui?.title) return `${getStateVerb(toolCall.state)} ${toolCall.ui.title}`
const fromStore = (toolCall as any).display?.text
if (fromStore) return fromStore
const registryEntry = TOOL_DISPLAY_REGISTRY[toolCall.name]
@@ -1401,37 +1342,53 @@ function RunSkipButtons({
toolCall,
onStateChange,
editedParams,
actions,
}: {
toolCall: CopilotToolCall
onStateChange?: (state: any) => void
editedParams?: any
actions: ToolUiAction[]
}) {
const [isProcessing, setIsProcessing] = useState(false)
const [buttonsHidden, setButtonsHidden] = useState(false)
const actionInProgressRef = useRef(false)
const { setToolCallState } = useCopilotStore()
const { setToolCallState, addAutoAllowedTool } = useCopilotStore()
const onAction = async (action: ToolUiAction) => {
const onRun = async () => {
// Prevent race condition - check ref synchronously
if (actionInProgressRef.current) return
actionInProgressRef.current = true
setIsProcessing(true)
setButtonsHidden(true)
try {
const decision = actionDecision(action)
if (decision === 'accepted') {
await handleRun(toolCall, setToolCallState, onStateChange, editedParams, {
remember: action.remember === true,
})
} else if (decision === 'rejected') {
await handleSkip(toolCall, setToolCallState, onStateChange)
} else {
setToolCallState(toolCall, ClientToolCallState.background)
onStateChange?.('background')
await sendToolDecision(toolCall.id, 'background')
}
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
} finally {
setIsProcessing(false)
actionInProgressRef.current = false
}
}
const onAlwaysAllow = async () => {
// Prevent race condition - check ref synchronously
if (actionInProgressRef.current) return
actionInProgressRef.current = true
setIsProcessing(true)
setButtonsHidden(true)
try {
await addAutoAllowedTool(toolCall.name)
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
} finally {
setIsProcessing(false)
actionInProgressRef.current = false
}
}
const onSkip = async () => {
// Prevent race condition - check ref synchronously
if (actionInProgressRef.current) return
actionInProgressRef.current = true
setIsProcessing(true)
setButtonsHidden(true)
try {
await handleSkip(toolCall, setToolCallState, onStateChange)
} finally {
setIsProcessing(false)
actionInProgressRef.current = false
@@ -1440,22 +1397,23 @@ function RunSkipButtons({
if (buttonsHidden) return null
// Show "Always Allow" for all tools that require confirmation
const showAlwaysAllow = true
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
return (
<div className='mt-[10px] flex gap-[6px]'>
{actions.map((action, index) => {
const variant =
action.kind === 'reject' ? 'default' : action.remember ? 'default' : 'tertiary'
return (
<Button
key={action.id}
onClick={() => onAction(action)}
disabled={isProcessing}
variant={variant}
>
{isProcessing && index === 0 ? 'Working...' : action.label}
</Button>
)
})}
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
{isProcessing ? 'Allowing...' : 'Allow'}
</Button>
{showAlwaysAllow && (
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
{isProcessing ? 'Allowing...' : 'Always Allow'}
</Button>
)}
<Button onClick={onSkip} disabled={isProcessing} variant='default'>
Skip
</Button>
</div>
)
}
@@ -1472,16 +1430,10 @@ export function ToolCall({
const liveToolCall = useCopilotStore((s) =>
effectiveId ? s.toolCallsById[effectiveId] : undefined
)
const rawToolCall = liveToolCall || toolCallProp
const hasRealToolCall = !!rawToolCall
const toolCall: CopilotToolCall =
rawToolCall ||
({
id: effectiveId || '',
name: '',
state: ClientToolCallState.generating,
params: {},
} as CopilotToolCall)
const toolCall = liveToolCall || toolCallProp
// Guard: nothing to render without a toolCall
if (!toolCall) return null
const isExpandablePending =
toolCall?.state === 'pending' &&
@@ -1489,15 +1441,17 @@ export function ToolCall({
const [expanded, setExpanded] = useState(isExpandablePending)
const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false)
const [autoAllowRemovedForCall, setAutoAllowRemovedForCall] = useState(false)
// State for editable parameters
const params = (toolCall as any).parameters || (toolCall as any).input || toolCall.params || {}
const [editedParams, setEditedParams] = useState(params)
const paramsRef = useRef(params)
const { setToolCallState } = useCopilotStore()
const isAutoAllowed = toolCall.ui?.autoAllowed === true && !autoAllowRemovedForCall
// Check if this integration tool is auto-allowed
const { removeAutoAllowedTool, setToolCallState } = useCopilotStore()
const isAutoAllowed = useCopilotStore(
(s) => isIntegrationTool(toolCall.name) && s.isToolAutoAllowed(toolCall.name)
)
// Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change)
useEffect(() => {
@@ -1507,14 +1461,6 @@ export function ToolCall({
}
}, [params])
useEffect(() => {
setAutoAllowRemovedForCall(false)
setShowRemoveAutoAllow(false)
}, [toolCall.id])
// Guard: nothing to render without a toolCall
if (!hasRealToolCall) return null
// Skip rendering some internal tools
if (
toolCall.name === 'checkoff_todo' ||
@@ -1526,9 +1472,7 @@ export function ToolCall({
return null
// Special rendering for subagent tools - show as thinking text with tool calls at top level
const isSubagentTool =
toolCall.execution?.target === 'go_subagent' ||
TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.subagent === true
const isSubagentTool = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.subagent === true
// For ALL subagent tools, don't show anything until we have blocks with content
if (isSubagentTool) {
@@ -1555,6 +1499,28 @@ export function ToolCall({
)
}
// Get current mode from store to determine if we should render integration tools
const mode = useCopilotStore.getState().mode
// Check if this is a completed/historical tool call (not pending/executing)
// Use string comparison to handle both enum values and string values from DB
const stateStr = String(toolCall.state)
const isCompletedToolCall =
stateStr === 'success' ||
stateStr === 'error' ||
stateStr === 'rejected' ||
stateStr === 'aborted'
// Allow rendering if:
// 1. Tool is in TOOL_DISPLAY_REGISTRY (client tools), OR
// 2. We're in build mode (integration tools are executed server-side), OR
// 3. Tool call is already completed (historical - should always render)
const isClientTool = !!TOOL_DISPLAY_REGISTRY[toolCall.name]
const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool
if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) {
return null
}
const toolUIConfig = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig
// Check if tool has params table config (meaning it's expandable)
const hasParamsTable = !!toolUIConfig?.paramsTable
@@ -1564,14 +1530,6 @@ export function ToolCall({
toolCall.name === 'make_api_request' ||
toolCall.name === 'set_global_workflow_variables'
const interruptActions =
(toolCall.ui?.actions && toolCall.ui.actions.length > 0
? toolCall.ui.actions
: [
{ id: 'allow_once', label: 'Allow', kind: 'accept' as const },
{ id: 'allow_always', label: 'Always Allow', kind: 'accept' as const, remember: true },
{ id: 'reject', label: 'Skip', kind: 'reject' as const },
]) as ToolUiAction[]
const showButtons = isCurrentMessage && shouldShowRunSkipButtons(toolCall)
// Check UI config for secondary action - only show for current message tool calls
@@ -2029,12 +1987,9 @@ export function ToolCall({
<div className='mt-[10px]'>
<Button
onClick={async () => {
const removed = await removeAutoAllowedToolPreference(toolCall.name)
if (removed) {
setAutoAllowRemovedForCall(true)
setShowRemoveAutoAllow(false)
forceUpdate({})
}
await removeAutoAllowedTool(toolCall.name)
setShowRemoveAutoAllow(false)
forceUpdate({})
}}
variant='default'
className='text-xs'
@@ -2048,7 +2003,6 @@ export function ToolCall({
toolCall={toolCall}
onStateChange={handleStateChange}
editedParams={editedParams}
actions={interruptActions}
/>
)}
{/* Render subagent content as thinking text */}
@@ -2094,12 +2048,9 @@ export function ToolCall({
<div className='mt-[10px]'>
<Button
onClick={async () => {
const removed = await removeAutoAllowedToolPreference(toolCall.name)
if (removed) {
setAutoAllowRemovedForCall(true)
setShowRemoveAutoAllow(false)
forceUpdate({})
}
await removeAutoAllowedTool(toolCall.name)
setShowRemoveAutoAllow(false)
forceUpdate({})
}}
variant='default'
className='text-xs'
@@ -2113,7 +2064,6 @@ export function ToolCall({
toolCall={toolCall}
onStateChange={handleStateChange}
editedParams={editedParams}
actions={interruptActions}
/>
)}
{/* Render subagent content as thinking text */}
@@ -2137,7 +2087,7 @@ export function ToolCall({
}
}
const isEditWorkflow = isWorkflowEditSummaryTool(toolCall)
const isEditWorkflow = toolCall.name === 'edit_workflow'
const shouldShowDetails = isRunWorkflow || (isExpandableTool && expanded)
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
@@ -2159,12 +2109,9 @@ export function ToolCall({
<div className='mt-[10px]'>
<Button
onClick={async () => {
const removed = await removeAutoAllowedToolPreference(toolCall.name)
if (removed) {
setAutoAllowRemovedForCall(true)
setShowRemoveAutoAllow(false)
forceUpdate({})
}
await removeAutoAllowedTool(toolCall.name)
setShowRemoveAutoAllow(false)
forceUpdate({})
}}
variant='default'
className='text-xs'
@@ -2178,7 +2125,6 @@ export function ToolCall({
toolCall={toolCall}
onStateChange={handleStateChange}
editedParams={editedParams}
actions={interruptActions}
/>
) : showMoveToBackground ? (
<div className='mt-[10px]'>
@@ -2209,7 +2155,7 @@ export function ToolCall({
</Button>
</div>
) : null}
{/* Workflow edit summary - shows block changes after edit_workflow/workflow_change(apply) */}
{/* Workflow edit summary - shows block changes after edit_workflow completes */}
<WorkflowEditSummary toolCall={toolCall} />
{/* Render subagent content as thinking text */}

View File

@@ -113,6 +113,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
clearPlanArtifact,
savePlanArtifact,
loadAvailableModels,
loadAutoAllowedTools,
resumeActiveStream,
} = useCopilotStore()
@@ -124,6 +125,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
setCopilotWorkflowId,
loadChats,
loadAvailableModels,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
resumeActiveStream,
})

View File

@@ -12,6 +12,8 @@ interface UseCopilotInitializationProps {
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
loadChats: (forceRefresh?: boolean) => Promise<void>
loadAvailableModels: () => Promise<void>
loadAutoAllowedTools: () => Promise<void>
currentChat: any
isSendingMessage: boolean
resumeActiveStream: () => Promise<boolean>
}
@@ -30,6 +32,8 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
setCopilotWorkflowId,
loadChats,
loadAvailableModels,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
resumeActiveStream,
} = props
@@ -116,6 +120,17 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
})
}, [isSendingMessage, resumeActiveStream])
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
const hasLoadedAutoAllowedToolsRef = useRef(false)
useEffect(() => {
if (!hasLoadedAutoAllowedToolsRef.current) {
hasLoadedAutoAllowedToolsRef.current = true
loadAutoAllowedTools().catch((err) => {
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
})
}
}, [loadAutoAllowedTools])
/** Load available models once on mount */
const hasLoadedModelsRef = useRef(false)
useEffect(() => {

View File

@@ -57,6 +57,21 @@ export function useChangeDetection({
}
}
if (block.triggerMode) {
const triggerConfigValue = blockSubValues?.triggerConfig
if (
triggerConfigValue &&
typeof triggerConfigValue === 'object' &&
!subBlocks.triggerConfig
) {
subBlocks.triggerConfig = {
id: 'triggerConfig',
type: 'short-input',
value: triggerConfigValue,
}
}
}
blocksWithSubBlocks[blockId] = {
...block,
subBlocks,

View File

@@ -139,6 +139,46 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
'write:issue-link:jira': 'Create links between Jira issues',
'delete:issue-link:jira': 'Delete links between Jira issues',
'manage:jira-project': 'Manage Jira project components and versions',
'read:board-scope:jira-software': 'View Jira boards',
'write:board-scope:jira-software': 'Manage Jira boards and backlog',
'read:sprint:jira-software': 'View Jira sprints',
'write:sprint:jira-software': 'Create and manage Jira sprints',
'delete:sprint:jira-software': 'Delete Jira sprints',
'read:servicedesk:jira-service-management': 'View JSM service desks',
'read:requesttype:jira-service-management': 'View JSM request types',
'read:request:jira-service-management': 'View JSM service requests',
'write:request:jira-service-management': 'Create and update JSM service requests',
'read:request.comment:jira-service-management': 'View comments on JSM requests',
'write:request.comment:jira-service-management': 'Add comments to JSM requests',
'read:customer:jira-service-management': 'View JSM customers',
'write:customer:jira-service-management': 'Create and manage JSM customers',
'read:servicedesk.customer:jira-service-management': 'View service desk customers',
'write:servicedesk.customer:jira-service-management': 'Add customers to service desks',
'delete:servicedesk.customer:jira-service-management': 'Remove customers from service desks',
'read:organization:jira-service-management': 'View JSM organizations',
'write:organization:jira-service-management': 'Create and manage JSM organizations',
'delete:organization:jira-service-management': 'Delete JSM organizations',
'read:servicedesk.organization:jira-service-management': 'View service desk organizations',
'write:servicedesk.organization:jira-service-management': 'Add organizations to service desks',
'read:organization.user:jira-service-management': 'View organization users',
'write:organization.user:jira-service-management': 'Add users to organizations',
'read:queue:jira-service-management': 'View JSM queues and queue issues',
'read:request.sla:jira-service-management': 'View request SLA information',
'read:request.status:jira-service-management': 'View request status history',
'write:request.status:jira-service-management': 'Transition request status',
'read:request.participant:jira-service-management': 'View request participants',
'write:request.participant:jira-service-management': 'Add request participants',
'read:request.approval:jira-service-management': 'View request approvals',
'write:request.approval:jira-service-management': 'Respond to request approvals',
'read:request.feedback:jira-service-management': 'View request feedback',
'write:request.feedback:jira-service-management': 'Add request feedback',
'delete:request.feedback:jira-service-management': 'Delete request feedback',
'read:request.notification:jira-service-management': 'View request notification status',
'write:request.notification:jira-service-management': 'Subscribe to request notifications',
'delete:request.notification:jira-service-management': 'Unsubscribe from request notifications',
'read:request.attachment:jira-service-management': 'View request attachments',
'read:knowledgebase:jira-service-management': 'Search knowledge base articles',
'User.Read': 'Read Microsoft user',
'Chat.Read': 'Read Microsoft chats',
'Chat.ReadWrite': 'Write to Microsoft chats',

View File

@@ -3,6 +3,7 @@ import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import type { ConfluenceResponse } from '@/tools/confluence/types'
import { getTrigger } from '@/triggers'
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
type: 'confluence',
@@ -394,6 +395,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
// Page Property Operations
{ label: 'List Page Properties', id: 'list_page_properties' },
{ label: 'Create Page Property', id: 'create_page_property' },
{ label: 'Update Page Property', id: 'update_page_property' },
{ label: 'Delete Page Property', id: 'delete_page_property' },
// Search Operations
{ label: 'Search Content', id: 'search' },
@@ -402,6 +404,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
{ label: 'List Blog Posts', id: 'list_blogposts' },
{ label: 'Get Blog Post', id: 'get_blogpost' },
{ label: 'Create Blog Post', id: 'create_blogpost' },
{ label: 'Update Blog Post', id: 'update_blogpost' },
{ label: 'Delete Blog Post', id: 'delete_blogpost' },
{ label: 'List Blog Posts in Space', id: 'list_blogposts_in_space' },
// Comment Operations
{ label: 'Create Comment', id: 'create_comment' },
@@ -484,6 +488,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_pages_in_space',
'list_blogposts',
'get_blogpost',
'create_blogpost',
'update_blogpost',
'delete_blogpost',
'list_blogposts_in_space',
'search',
'search_in_space',
@@ -508,6 +515,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'add_label',
'delete_label',
'delete_page_property',
'update_page_property',
'get_page_children',
'get_page_ancestors',
'list_page_versions',
@@ -530,6 +538,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_pages_in_space',
'list_blogposts',
'get_blogpost',
'create_blogpost',
'update_blogpost',
'delete_blogpost',
'list_blogposts_in_space',
'search',
'search_in_space',
@@ -554,6 +565,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'add_label',
'delete_label',
'delete_page_property',
'update_page_property',
'get_page_children',
'get_page_ancestors',
'list_page_versions',
@@ -588,7 +600,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
type: 'short-input',
placeholder: 'Enter blog post ID',
required: true,
condition: { field: 'operation', value: 'get_blogpost' },
condition: {
field: 'operation',
value: ['get_blogpost', 'update_blogpost', 'delete_blogpost'],
},
},
{
id: 'versionNumber',
@@ -604,7 +619,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
type: 'short-input',
placeholder: 'Enter property key/name',
required: true,
condition: { field: 'operation', value: 'create_page_property' },
condition: { field: 'operation', value: ['create_page_property', 'update_page_property'] },
},
{
id: 'propertyValue',
@@ -612,29 +627,46 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
type: 'long-input',
placeholder: 'Enter property value (JSON supported)',
required: true,
condition: { field: 'operation', value: 'create_page_property' },
condition: { field: 'operation', value: ['create_page_property', 'update_page_property'] },
},
{
id: 'propertyId',
title: 'Property ID',
type: 'short-input',
placeholder: 'Enter property ID to delete',
placeholder: 'Enter property ID',
required: true,
condition: { field: 'operation', value: 'delete_page_property' },
condition: {
field: 'operation',
value: ['delete_page_property', 'update_page_property'],
},
},
{
id: 'propertyVersionNumber',
title: 'Property Version Number',
type: 'short-input',
placeholder: 'Enter current version number of the property',
required: true,
condition: { field: 'operation', value: 'update_page_property' },
},
{
id: 'title',
title: 'Title',
type: 'short-input',
placeholder: 'Enter title',
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
condition: {
field: 'operation',
value: ['create', 'update', 'create_blogpost', 'update_blogpost'],
},
},
{
id: 'content',
title: 'Content',
type: 'long-input',
placeholder: 'Enter content',
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
condition: {
field: 'operation',
value: ['create', 'update', 'create_blogpost', 'update_blogpost'],
},
},
{
id: 'parentId',
@@ -747,7 +779,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
{ label: 'Draft', id: 'draft' },
],
value: () => 'current',
condition: { field: 'operation', value: 'create_blogpost' },
condition: { field: 'operation', value: ['create_blogpost', 'update_blogpost'] },
},
{
id: 'purge',
@@ -816,7 +848,46 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
],
},
},
// Trigger subBlocks
...getTrigger('confluence_page_created').subBlocks,
...getTrigger('confluence_page_updated').subBlocks,
...getTrigger('confluence_page_removed').subBlocks,
...getTrigger('confluence_page_moved').subBlocks,
...getTrigger('confluence_comment_created').subBlocks,
...getTrigger('confluence_comment_removed').subBlocks,
...getTrigger('confluence_blog_created').subBlocks,
...getTrigger('confluence_blog_updated').subBlocks,
...getTrigger('confluence_blog_removed').subBlocks,
...getTrigger('confluence_attachment_created').subBlocks,
...getTrigger('confluence_attachment_removed').subBlocks,
...getTrigger('confluence_space_created').subBlocks,
...getTrigger('confluence_space_updated').subBlocks,
...getTrigger('confluence_label_added').subBlocks,
...getTrigger('confluence_label_removed').subBlocks,
...getTrigger('confluence_webhook').subBlocks,
],
triggers: {
enabled: true,
available: [
'confluence_page_created',
'confluence_page_updated',
'confluence_page_removed',
'confluence_page_moved',
'confluence_comment_created',
'confluence_comment_removed',
'confluence_blog_created',
'confluence_blog_updated',
'confluence_blog_removed',
'confluence_attachment_created',
'confluence_attachment_removed',
'confluence_space_created',
'confluence_space_updated',
'confluence_label_added',
'confluence_label_removed',
'confluence_webhook',
],
},
tools: {
access: [
// Page Tools
@@ -833,6 +904,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
// Property Tools
'confluence_list_page_properties',
'confluence_create_page_property',
'confluence_update_page_property',
'confluence_delete_page_property',
// Search Tools
'confluence_search',
@@ -841,6 +913,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'confluence_list_blogposts',
'confluence_get_blogpost',
'confluence_create_blogpost',
'confluence_update_blogpost',
'confluence_delete_blogpost',
'confluence_list_blogposts_in_space',
// Comment Tools
'confluence_create_comment',
@@ -889,6 +963,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
return 'confluence_list_page_properties'
case 'create_page_property':
return 'confluence_create_page_property'
case 'update_page_property':
return 'confluence_update_page_property'
case 'delete_page_property':
return 'confluence_delete_page_property'
// Search Operations
@@ -903,6 +979,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
return 'confluence_get_blogpost'
case 'create_blogpost':
return 'confluence_create_blogpost'
case 'update_blogpost':
return 'confluence_update_blogpost'
case 'delete_blogpost':
return 'confluence_delete_blogpost'
case 'list_blogposts_in_space':
return 'confluence_list_blogposts_in_space'
// Comment Operations
@@ -954,6 +1034,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
propertyKey,
propertyValue,
propertyId,
propertyVersionNumber,
labelPrefix,
labelId,
blogPostStatus,
@@ -985,6 +1066,25 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
}
}
if (operation === 'update_blogpost') {
return {
credential,
operation,
blogPostId,
status: blogPostStatus || undefined,
...rest,
}
}
if (operation === 'delete_blogpost') {
return {
credential,
operation,
blogPostId,
...rest,
}
}
if (operation === 'delete') {
return {
credential,
@@ -1045,6 +1145,24 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
}
}
if (operation === 'update_page_property') {
if (!propertyKey) {
throw new Error('Property key is required for this operation.')
}
return {
credential,
pageId: effectivePageId,
operation,
propertyId,
key: propertyKey,
value: propertyValue,
versionNumber: propertyVersionNumber
? Number.parseInt(String(propertyVersionNumber), 10)
: undefined,
...rest,
}
}
if (operation === 'delete_page_property') {
return {
credential,
@@ -1125,6 +1243,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
labelId: { type: 'string', description: 'Label identifier' },
labelPrefix: { type: 'string', description: 'Label prefix (global, my, team, system)' },
propertyId: { type: 'string', description: 'Property identifier' },
propertyVersionNumber: {
type: 'number',
description: 'Current version number of the property',
},
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
bodyFormat: { type: 'string', description: 'Body format for comments' },

View File

@@ -93,6 +93,12 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'delete:issue-worklog:jira',
'write:issue-link:jira',
'delete:issue-link:jira',
'manage:jira-project',
'read:board-scope:jira-software',
'write:board-scope:jira-software',
'read:sprint:jira-software',
'write:sprint:jira-software',
'delete:sprint:jira-software',
],
placeholder: 'Select Jira account',
},
@@ -695,7 +701,32 @@ Return ONLY the comment text - no explanations.`,
...getTrigger('jira_issue_updated').subBlocks,
...getTrigger('jira_issue_deleted').subBlocks,
...getTrigger('jira_issue_commented').subBlocks,
...getTrigger('jira_comment_updated').subBlocks,
...getTrigger('jira_comment_deleted').subBlocks,
...getTrigger('jira_worklog_created').subBlocks,
...getTrigger('jira_worklog_updated').subBlocks,
...getTrigger('jira_worklog_deleted').subBlocks,
...getTrigger('jira_sprint_created').subBlocks,
...getTrigger('jira_sprint_started').subBlocks,
...getTrigger('jira_sprint_closed').subBlocks,
...getTrigger('jira_sprint_updated').subBlocks,
...getTrigger('jira_sprint_deleted').subBlocks,
...getTrigger('jira_project_created').subBlocks,
...getTrigger('jira_project_updated').subBlocks,
...getTrigger('jira_project_deleted').subBlocks,
...getTrigger('jira_version_created').subBlocks,
...getTrigger('jira_version_released').subBlocks,
...getTrigger('jira_version_unreleased').subBlocks,
...getTrigger('jira_version_updated').subBlocks,
...getTrigger('jira_version_deleted').subBlocks,
...getTrigger('jira_board_created').subBlocks,
...getTrigger('jira_board_updated').subBlocks,
...getTrigger('jira_board_deleted').subBlocks,
...getTrigger('jira_board_config_changed').subBlocks,
...getTrigger('jira_attachment_created').subBlocks,
...getTrigger('jira_attachment_deleted').subBlocks,
...getTrigger('jira_issuelink_created').subBlocks,
...getTrigger('jira_issuelink_deleted').subBlocks,
...getTrigger('jira_webhook').subBlocks,
],
tools: {
@@ -1240,7 +1271,32 @@ Return ONLY the comment text - no explanations.`,
'jira_issue_updated',
'jira_issue_deleted',
'jira_issue_commented',
'jira_comment_updated',
'jira_comment_deleted',
'jira_worklog_created',
'jira_worklog_updated',
'jira_worklog_deleted',
'jira_sprint_created',
'jira_sprint_started',
'jira_sprint_closed',
'jira_sprint_updated',
'jira_sprint_deleted',
'jira_project_created',
'jira_project_updated',
'jira_project_deleted',
'jira_version_created',
'jira_version_released',
'jira_version_unreleased',
'jira_version_updated',
'jira_version_deleted',
'jira_board_created',
'jira_board_updated',
'jira_board_deleted',
'jira_board_config_changed',
'jira_attachment_created',
'jira_attachment_deleted',
'jira_issuelink_created',
'jira_issuelink_deleted',
'jira_webhook',
],
},

View File

@@ -2,14 +2,16 @@ import { JiraServiceManagementIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { JsmResponse } from '@/tools/jsm/types'
import { getTrigger } from '@/triggers'
export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
type: 'jira_service_management',
name: 'Jira Service Management',
description: 'Interact with Jira Service Management',
authMode: AuthMode.OAuth,
triggerAllowed: true,
longDescription:
'Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues.',
'Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues. Can also trigger workflows based on Jira Service Management webhook events.',
docsLink: 'https://docs.sim.ai/tools/jira-service-management',
category: 'tools',
bgColor: '#E0E0E0',
@@ -21,26 +23,46 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
type: 'dropdown',
options: [
{ label: 'Get Service Desks', id: 'get_service_desks' },
{ label: 'Get Service Desk', id: 'get_service_desk' },
{ label: 'Get Request Types', id: 'get_request_types' },
{ label: 'Get Request Type Fields', id: 'get_request_type_fields' },
{ label: 'Create Request', id: 'create_request' },
{ label: 'Get Request', id: 'get_request' },
{ label: 'Get Requests', id: 'get_requests' },
{ label: 'Get Request Status', id: 'get_request_status' },
{ label: 'Get Request Attachments', id: 'get_request_attachments' },
{ label: 'Add Comment', id: 'add_comment' },
{ label: 'Get Comments', id: 'get_comments' },
{ label: 'Get Customers', id: 'get_customers' },
{ label: 'Add Customer', id: 'add_customer' },
{ label: 'Remove Customer', id: 'remove_customer' },
{ label: 'Create Customer', id: 'create_customer' },
{ label: 'Get Organizations', id: 'get_organizations' },
{ label: 'Get Organization', id: 'get_organization' },
{ label: 'Create Organization', id: 'create_organization' },
{ label: 'Add Organization', id: 'add_organization' },
{ label: 'Remove Organization', id: 'remove_organization' },
{ label: 'Delete Organization', id: 'delete_organization' },
{ label: 'Get Organization Users', id: 'get_organization_users' },
{ label: 'Add Organization Users', id: 'add_organization_users' },
{ label: 'Remove Organization Users', id: 'remove_organization_users' },
{ label: 'Get Queues', id: 'get_queues' },
{ label: 'Get Queue Issues', id: 'get_queue_issues' },
{ label: 'Get SLA', id: 'get_sla' },
{ label: 'Get Transitions', id: 'get_transitions' },
{ label: 'Transition Request', id: 'transition_request' },
{ label: 'Get Participants', id: 'get_participants' },
{ label: 'Add Participants', id: 'add_participants' },
{ label: 'Remove Participants', id: 'remove_participants' },
{ label: 'Get Approvals', id: 'get_approvals' },
{ label: 'Answer Approval', id: 'answer_approval' },
{ label: 'Get Request Type Fields', id: 'get_request_type_fields' },
{ label: 'Get Feedback', id: 'get_feedback' },
{ label: 'Add Feedback', id: 'add_feedback' },
{ label: 'Delete Feedback', id: 'delete_feedback' },
{ label: 'Get Notification', id: 'get_notification' },
{ label: 'Subscribe Notification', id: 'subscribe_notification' },
{ label: 'Unsubscribe Notification', id: 'unsubscribe_notification' },
{ label: 'Search Knowledge Base', id: 'search_knowledge_base' },
],
value: () => 'get_service_desks',
},
@@ -92,6 +114,18 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
'write:request.participant:jira-service-management',
'read:request.approval:jira-service-management',
'write:request.approval:jira-service-management',
'read:request.feedback:jira-service-management',
'write:request.feedback:jira-service-management',
'delete:request.feedback:jira-service-management',
'read:request.notification:jira-service-management',
'write:request.notification:jira-service-management',
'delete:request.notification:jira-service-management',
'read:request.attachment:jira-service-management',
'read:knowledgebase:jira-service-management',
'read:organization.user:jira-service-management',
'write:organization.user:jira-service-management',
'delete:organization:jira-service-management',
'delete:servicedesk.customer:jira-service-management',
],
placeholder: 'Select Jira account',
},
@@ -103,15 +137,20 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
condition: {
field: 'operation',
value: [
'get_service_desk',
'get_request_types',
'create_request',
'get_customers',
'add_customer',
'remove_customer',
'get_organizations',
'add_organization',
'remove_organization',
'get_queues',
'get_queue_issues',
'get_requests',
'get_request_type_fields',
'search_knowledge_base',
],
},
},
@@ -133,6 +172,8 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
field: 'operation',
value: [
'get_request',
'get_request_status',
'get_request_attachments',
'add_comment',
'get_comments',
'get_sla',
@@ -140,8 +181,15 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
'transition_request',
'get_participants',
'add_participants',
'remove_participants',
'get_approvals',
'answer_approval',
'get_feedback',
'add_feedback',
'delete_feedback',
'get_notification',
'subscribe_notification',
'unsubscribe_notification',
],
},
},
@@ -273,7 +321,15 @@ Return ONLY the comment text - no explanations.`,
type: 'short-input',
required: true,
placeholder: 'Comma-separated Atlassian account IDs',
condition: { field: 'operation', value: 'add_customer' },
condition: {
field: 'operation',
value: [
'add_customer',
'remove_customer',
'add_organization_users',
'remove_organization_users',
],
},
},
{
id: 'customerQuery',
@@ -366,7 +422,18 @@ Return ONLY the comment text - no explanations.`,
type: 'short-input',
required: true,
placeholder: 'Enter organization ID',
condition: { field: 'operation', value: 'add_organization' },
condition: {
field: 'operation',
value: [
'add_organization',
'remove_organization',
'delete_organization',
'get_organization',
'get_organization_users',
'add_organization_users',
'remove_organization_users',
],
},
},
{
id: 'participantAccountIds',
@@ -374,7 +441,7 @@ Return ONLY the comment text - no explanations.`,
type: 'short-input',
required: true,
placeholder: 'Comma-separated account IDs',
condition: { field: 'operation', value: 'add_participants' },
condition: { field: 'operation', value: ['add_participants', 'remove_participants'] },
},
{
id: 'approvalId',
@@ -406,55 +473,165 @@ Return ONLY the comment text - no explanations.`,
'get_service_desks',
'get_request_types',
'get_requests',
'get_request_status',
'get_request_attachments',
'get_comments',
'get_customers',
'get_organizations',
'get_organization_users',
'get_queues',
'get_queue_issues',
'get_sla',
'get_transitions',
'get_participants',
'get_approvals',
'search_knowledge_base',
],
},
},
{
id: 'queueId',
title: 'Queue ID',
type: 'short-input',
required: true,
placeholder: 'Enter queue ID',
condition: { field: 'operation', value: 'get_queue_issues' },
},
{
id: 'customerEmail',
title: 'Customer Email',
type: 'short-input',
required: true,
placeholder: 'Enter customer email address',
condition: { field: 'operation', value: 'create_customer' },
},
{
id: 'customerDisplayName',
title: 'Display Name',
type: 'short-input',
required: true,
placeholder: 'Enter customer display name',
condition: { field: 'operation', value: 'create_customer' },
},
{
id: 'knowledgeBaseQuery',
title: 'Search Query',
type: 'short-input',
required: true,
placeholder: 'Search knowledge base articles',
condition: { field: 'operation', value: 'search_knowledge_base' },
},
{
id: 'feedbackRating',
title: 'Rating',
type: 'dropdown',
options: [
{ label: '1 - Very Unsatisfied', id: '1' },
{ label: '2 - Unsatisfied', id: '2' },
{ label: '3 - Neutral', id: '3' },
{ label: '4 - Satisfied', id: '4' },
{ label: '5 - Very Satisfied', id: '5' },
],
value: () => '5',
condition: { field: 'operation', value: 'add_feedback' },
},
{
id: 'feedbackComment',
title: 'Feedback Comment',
type: 'long-input',
placeholder: 'Optional feedback comment',
condition: { field: 'operation', value: 'add_feedback' },
},
{
id: 'includeAttachments',
title: 'Include File Content',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'get_request_attachments' },
},
// Trigger SubBlocks
...getTrigger('jsm_request_created').subBlocks,
...getTrigger('jsm_request_updated').subBlocks,
...getTrigger('jsm_request_deleted').subBlocks,
...getTrigger('jsm_request_commented').subBlocks,
...getTrigger('jsm_comment_updated').subBlocks,
...getTrigger('jsm_comment_deleted').subBlocks,
...getTrigger('jsm_worklog_created').subBlocks,
...getTrigger('jsm_worklog_updated').subBlocks,
...getTrigger('jsm_worklog_deleted').subBlocks,
...getTrigger('jsm_attachment_created').subBlocks,
...getTrigger('jsm_attachment_deleted').subBlocks,
...getTrigger('jsm_webhook').subBlocks,
],
tools: {
access: [
'jsm_get_service_desks',
'jsm_get_service_desk',
'jsm_get_request_types',
'jsm_get_request_type_fields',
'jsm_create_request',
'jsm_get_request',
'jsm_get_requests',
'jsm_get_request_status',
'jsm_get_request_attachments',
'jsm_add_comment',
'jsm_get_comments',
'jsm_get_customers',
'jsm_add_customer',
'jsm_remove_customer',
'jsm_create_customer',
'jsm_get_organizations',
'jsm_get_organization',
'jsm_create_organization',
'jsm_add_organization',
'jsm_remove_organization',
'jsm_delete_organization',
'jsm_get_organization_users',
'jsm_add_organization_users',
'jsm_remove_organization_users',
'jsm_get_queues',
'jsm_get_queue_issues',
'jsm_get_sla',
'jsm_get_transitions',
'jsm_transition_request',
'jsm_get_participants',
'jsm_add_participants',
'jsm_remove_participants',
'jsm_get_approvals',
'jsm_answer_approval',
'jsm_get_request_type_fields',
'jsm_get_feedback',
'jsm_add_feedback',
'jsm_delete_feedback',
'jsm_get_notification',
'jsm_subscribe_notification',
'jsm_unsubscribe_notification',
'jsm_search_knowledge_base',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'get_service_desks':
return 'jsm_get_service_desks'
case 'get_service_desk':
return 'jsm_get_service_desk'
case 'get_request_types':
return 'jsm_get_request_types'
case 'get_request_type_fields':
return 'jsm_get_request_type_fields'
case 'create_request':
return 'jsm_create_request'
case 'get_request':
return 'jsm_get_request'
case 'get_requests':
return 'jsm_get_requests'
case 'get_request_status':
return 'jsm_get_request_status'
case 'get_request_attachments':
return 'jsm_get_request_attachments'
case 'add_comment':
return 'jsm_add_comment'
case 'get_comments':
@@ -463,14 +640,32 @@ Return ONLY the comment text - no explanations.`,
return 'jsm_get_customers'
case 'add_customer':
return 'jsm_add_customer'
case 'remove_customer':
return 'jsm_remove_customer'
case 'create_customer':
return 'jsm_create_customer'
case 'get_organizations':
return 'jsm_get_organizations'
case 'get_organization':
return 'jsm_get_organization'
case 'create_organization':
return 'jsm_create_organization'
case 'add_organization':
return 'jsm_add_organization'
case 'remove_organization':
return 'jsm_remove_organization'
case 'delete_organization':
return 'jsm_delete_organization'
case 'get_organization_users':
return 'jsm_get_organization_users'
case 'add_organization_users':
return 'jsm_add_organization_users'
case 'remove_organization_users':
return 'jsm_remove_organization_users'
case 'get_queues':
return 'jsm_get_queues'
case 'get_queue_issues':
return 'jsm_get_queue_issues'
case 'get_sla':
return 'jsm_get_sla'
case 'get_transitions':
@@ -481,12 +676,26 @@ Return ONLY the comment text - no explanations.`,
return 'jsm_get_participants'
case 'add_participants':
return 'jsm_add_participants'
case 'remove_participants':
return 'jsm_remove_participants'
case 'get_approvals':
return 'jsm_get_approvals'
case 'answer_approval':
return 'jsm_answer_approval'
case 'get_request_type_fields':
return 'jsm_get_request_type_fields'
case 'get_feedback':
return 'jsm_get_feedback'
case 'add_feedback':
return 'jsm_add_feedback'
case 'delete_feedback':
return 'jsm_delete_feedback'
case 'get_notification':
return 'jsm_get_notification'
case 'subscribe_notification':
return 'jsm_subscribe_notification'
case 'unsubscribe_notification':
return 'jsm_unsubscribe_notification'
case 'search_knowledge_base':
return 'jsm_search_knowledge_base'
default:
return 'jsm_get_service_desks'
}
@@ -731,6 +940,204 @@ Return ONLY the comment text - no explanations.`,
serviceDeskId: params.serviceDeskId,
requestTypeId: params.requestTypeId,
}
case 'get_service_desk':
if (!params.serviceDeskId) {
throw new Error('Service Desk ID is required')
}
return {
...baseParams,
serviceDeskId: params.serviceDeskId,
}
case 'get_request_status':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
}
case 'get_request_attachments':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
includeAttachments: params.includeAttachments === 'true',
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
}
case 'remove_customer': {
if (!params.serviceDeskId) {
throw new Error('Service Desk ID is required')
}
if (!params.accountIds) {
throw new Error('Account IDs are required')
}
return {
...baseParams,
serviceDeskId: params.serviceDeskId,
accountIds: params.accountIds,
}
}
case 'create_customer':
if (!params.customerEmail) {
throw new Error('Customer email is required')
}
if (!params.customerDisplayName) {
throw new Error('Customer display name is required')
}
return {
...baseParams,
email: params.customerEmail,
displayName: params.customerDisplayName,
}
case 'get_organization':
if (!params.organizationId) {
throw new Error('Organization ID is required')
}
return {
...baseParams,
organizationId: params.organizationId,
}
case 'remove_organization':
if (!params.serviceDeskId) {
throw new Error('Service Desk ID is required')
}
if (!params.organizationId) {
throw new Error('Organization ID is required')
}
return {
...baseParams,
serviceDeskId: params.serviceDeskId,
organizationId: params.organizationId,
}
case 'delete_organization':
if (!params.organizationId) {
throw new Error('Organization ID is required')
}
return {
...baseParams,
organizationId: params.organizationId,
}
case 'get_organization_users':
if (!params.organizationId) {
throw new Error('Organization ID is required')
}
return {
...baseParams,
organizationId: params.organizationId,
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
}
case 'add_organization_users':
if (!params.organizationId) {
throw new Error('Organization ID is required')
}
if (!params.accountIds) {
throw new Error('Account IDs are required')
}
return {
...baseParams,
organizationId: params.organizationId,
accountIds: params.accountIds,
}
case 'remove_organization_users':
if (!params.organizationId) {
throw new Error('Organization ID is required')
}
if (!params.accountIds) {
throw new Error('Account IDs are required')
}
return {
...baseParams,
organizationId: params.organizationId,
accountIds: params.accountIds,
}
case 'get_queue_issues':
if (!params.serviceDeskId) {
throw new Error('Service Desk ID is required')
}
if (!params.queueId) {
throw new Error('Queue ID is required')
}
return {
...baseParams,
serviceDeskId: params.serviceDeskId,
queueId: params.queueId,
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
}
case 'remove_participants':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
if (!params.participantAccountIds) {
throw new Error('Account IDs are required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
accountIds: params.participantAccountIds,
}
case 'get_feedback':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
}
case 'add_feedback':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
rating: Number.parseInt(params.feedbackRating || '5'),
comment: params.feedbackComment,
}
case 'delete_feedback':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
}
case 'get_notification':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
}
case 'subscribe_notification':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
}
case 'unsubscribe_notification':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
}
case 'search_knowledge_base':
if (!params.knowledgeBaseQuery) {
throw new Error('Search query is required')
}
return {
...baseParams,
serviceDeskId: params.serviceDeskId,
query: params.knowledgeBaseQuery,
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
}
default:
return baseParams
}
@@ -779,6 +1186,16 @@ Return ONLY the comment text - no explanations.`,
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' },
queueId: { type: 'string', description: 'Queue ID' },
customerEmail: { type: 'string', description: 'Customer email address' },
customerDisplayName: { type: 'string', description: 'Customer display name' },
knowledgeBaseQuery: { type: 'string', description: 'Knowledge base search query' },
feedbackRating: { type: 'string', description: 'CSAT feedback rating (1-5)' },
feedbackComment: { type: 'string', description: 'CSAT feedback comment' },
includeAttachments: {
type: 'string',
description: 'Whether to download attachment file content',
},
},
outputs: {
ts: { type: 'string', description: 'Timestamp of the operation' },
@@ -810,6 +1227,19 @@ Return ONLY the comment text - no explanations.`,
total: { type: 'number', description: 'Total count' },
isLastPage: { type: 'boolean', description: 'Whether this is the last page' },
requestTypeFields: { type: 'json', description: 'Array of request type fields' },
rating: { type: 'number', description: 'CSAT feedback rating' },
subscribed: { type: 'boolean', description: 'Whether subscribed to notifications' },
articles: { type: 'json', description: 'Array of knowledge base articles' },
statuses: { type: 'json', description: 'Array of request status history entries' },
attachments: { type: 'json', description: 'Array of attachment metadata' },
issues: { type: 'json', description: 'Array of queue issues' },
users: { type: 'json', description: 'Array of organization users' },
id: { type: 'string', description: 'Resource ID' },
projectId: { type: 'string', description: 'Service desk project ID' },
projectName: { type: 'string', description: 'Service desk project name' },
projectKey: { type: 'string', description: 'Service desk project key' },
email: { type: 'string', description: 'Customer email address' },
displayName: { type: 'string', description: 'Customer display name' },
canAddRequestParticipants: {
type: 'boolean',
description: 'Whether participants can be added to this request type',
@@ -818,5 +1248,36 @@ Return ONLY the comment text - no explanations.`,
type: 'boolean',
description: 'Whether requests can be raised on behalf of another user',
},
// Trigger outputs (from webhook events)
webhookEvent: { type: 'string', description: 'Webhook event type' },
issue: { type: 'json', description: 'Complete issue object from webhook' },
changelog: { type: 'json', description: 'Changelog object (for update events)' },
comment: { type: 'json', description: 'Comment object (for comment events)' },
worklog: { type: 'json', description: 'Worklog object (for worklog events)' },
attachment: { type: 'json', description: 'Attachment metadata (for attachment events)' },
files: {
type: 'file[]',
description:
'Downloaded file attachments (if includeFiles is enabled and Jira credentials are provided)',
},
user: { type: 'json', description: 'User object who triggered the event' },
webhook: { type: 'json', description: 'Complete webhook payload' },
},
triggers: {
enabled: true,
available: [
'jsm_request_created',
'jsm_request_updated',
'jsm_request_deleted',
'jsm_request_commented',
'jsm_comment_updated',
'jsm_comment_deleted',
'jsm_worklog_created',
'jsm_worklog_updated',
'jsm_worklog_deleted',
'jsm_attachment_created',
'jsm_attachment_deleted',
'jsm_webhook',
],
},
}

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
import { COPILOT_CONFIRM_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
import { asRecord } from '@/lib/copilot/orchestrator/sse-utils'
import type { SSEEvent } from '@/lib/copilot/orchestrator/types'
import {
@@ -26,119 +26,21 @@ const MAX_BATCH_INTERVAL = 50
const MIN_BATCH_INTERVAL = 16
const MAX_QUEUE_SIZE = 5
function isWorkflowEditToolCall(toolName?: string, params?: Record<string, unknown>): boolean {
if (toolName === 'edit_workflow') return true
if (toolName !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
return typeof params?.proposalId === 'string' && params.proposalId.length > 0
}
function isClientRunCapability(toolCall: CopilotToolCall): boolean {
if (toolCall.execution?.target === 'sim_client_capability') {
return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId
}
return CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)
}
function mapServerStateToClientState(state: unknown): ClientToolCallState {
switch (String(state || '')) {
case 'generating':
return ClientToolCallState.generating
case 'pending':
case 'awaiting_approval':
return ClientToolCallState.pending
case 'executing':
return ClientToolCallState.executing
case 'success':
return ClientToolCallState.success
case 'rejected':
case 'skipped':
return ClientToolCallState.rejected
case 'aborted':
return ClientToolCallState.aborted
case 'error':
case 'failed':
return ClientToolCallState.error
default:
return ClientToolCallState.pending
}
}
function extractToolUiMetadata(data: Record<string, unknown>): CopilotToolCall['ui'] | undefined {
const ui = asRecord(data.ui)
if (!ui || Object.keys(ui).length === 0) return undefined
const autoAllowedFromUi = ui.autoAllowed === true
const autoAllowedFromData = data.autoAllowed === true
return {
title: typeof ui.title === 'string' ? ui.title : undefined,
phaseLabel: typeof ui.phaseLabel === 'string' ? ui.phaseLabel : undefined,
icon: typeof ui.icon === 'string' ? ui.icon : undefined,
showInterrupt: ui.showInterrupt === true,
showRemember: ui.showRemember === true,
autoAllowed: autoAllowedFromUi || autoAllowedFromData,
actions: Array.isArray(ui.actions)
? ui.actions
.map((action) => {
const a = asRecord(action)
const id = typeof a.id === 'string' ? a.id : undefined
const label = typeof a.label === 'string' ? a.label : undefined
const kind: 'accept' | 'reject' = a.kind === 'reject' ? 'reject' : 'accept'
if (!id || !label) return null
return {
id,
label,
kind,
remember: a.remember === true,
}
})
.filter((a): a is NonNullable<typeof a> => !!a)
: undefined,
}
}
function extractToolExecutionMetadata(
data: Record<string, unknown>
): CopilotToolCall['execution'] | undefined {
const execution = asRecord(data.execution)
if (!execution || Object.keys(execution).length === 0) return undefined
return {
target: typeof execution.target === 'string' ? execution.target : undefined,
capabilityId: typeof execution.capabilityId === 'string' ? execution.capabilityId : undefined,
}
}
function isWorkflowChangeApplyCall(toolName?: string, params?: Record<string, unknown>): boolean {
if (toolName !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
return typeof params?.proposalId === 'string' && params.proposalId.length > 0
}
function extractWorkflowStateFromResultPayload(
resultPayload: Record<string, unknown>
): WorkflowState | null {
const directState = asRecord(resultPayload.workflowState)
if (directState) return directState as unknown as WorkflowState
const editResult = asRecord(resultPayload.editResult)
const nestedState = asRecord(editResult?.workflowState)
if (nestedState) return nestedState as unknown as WorkflowState
return null
}
function extractOperationListFromResultPayload(
resultPayload: Record<string, unknown>
): Array<Record<string, unknown>> | undefined {
const operations = resultPayload.operations
if (Array.isArray(operations)) return operations as Array<Record<string, unknown>>
const compiled = resultPayload.compiledOperations
if (Array.isArray(compiled)) return compiled as Array<Record<string, unknown>>
return undefined
/**
* Send an auto-accept confirmation to the server for auto-allowed tools.
* The server-side orchestrator polls Redis for this decision.
*/
export function sendAutoAcceptConfirmation(toolCallId: string): void {
fetch(COPILOT_CONFIRM_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolCallId, status: 'accepted' }),
}).catch((error) => {
logger.warn('Failed to send auto-accept confirmation', {
toolCallId,
error: error instanceof Error ? error.message : String(error),
})
})
}
function writeActiveStreamToStorage(info: CopilotStreamInfo | null): void {
@@ -342,28 +244,14 @@ export const sseHandlers: Record<string, SSEHandler> = {
try {
const eventData = asRecord(data?.data)
const toolCallId: string | undefined =
data?.toolCallId ||
(eventData.id as string | undefined) ||
(eventData.callId as string | undefined)
data?.toolCallId || (eventData.id as string | undefined)
const success: boolean | undefined = data?.success
const failedDependency: boolean = data?.failedDependency === true
const resultObj = asRecord(data?.result)
const skipped: boolean = resultObj.skipped === true
if (!toolCallId) return
const uiMetadata = extractToolUiMetadata(eventData)
const executionMetadata = extractToolExecutionMetadata(eventData)
const serverState = (eventData.state as string | undefined) || undefined
const targetState = serverState
? mapServerStateToClientState(serverState)
: success
? ClientToolCallState.success
: failedDependency || skipped
? ClientToolCallState.rejected
: ClientToolCallState.error
const resultPayload = asRecord(data?.result || eventData.result || eventData.data || data?.data)
const { toolCallsById } = get()
const current = toolCallsById[toolCallId]
let paramsForCurrentToolCall: Record<string, unknown> | undefined = current?.params
if (current) {
if (
isRejectedState(current.state) ||
@@ -372,32 +260,16 @@ export const sseHandlers: Record<string, SSEHandler> = {
) {
return
}
if (
targetState === ClientToolCallState.success &&
isWorkflowChangeApplyCall(current.name, paramsForCurrentToolCall)
) {
const operations = extractOperationListFromResultPayload(resultPayload || {})
if (operations && operations.length > 0) {
paramsForCurrentToolCall = {
...(current.params || {}),
operations,
}
}
}
const targetState = success
? ClientToolCallState.success
: failedDependency || skipped
? ClientToolCallState.rejected
: ClientToolCallState.error
const updatedMap = { ...toolCallsById }
updatedMap[toolCallId] = {
...current,
ui: uiMetadata || current.ui,
execution: executionMetadata || current.execution,
params: paramsForCurrentToolCall,
state: targetState,
display: resolveToolDisplay(
current.name,
targetState,
current.id,
paramsForCurrentToolCall
),
display: resolveToolDisplay(current.name, targetState, current.id, current.params),
}
set({ toolCallsById: updatedMap })
@@ -440,39 +312,31 @@ export const sseHandlers: Record<string, SSEHandler> = {
}
}
if (
targetState === ClientToolCallState.success &&
isWorkflowEditToolCall(current.name, paramsForCurrentToolCall)
) {
if (current.name === 'edit_workflow') {
try {
const workflowState = resultPayload
? extractWorkflowStateFromResultPayload(resultPayload)
: null
const hasWorkflowState = !!workflowState
logger.info('[SSE] workflow edit result received', {
toolName: current.name,
const resultPayload = asRecord(
data?.result || eventData.result || eventData.data || data?.data
)
const workflowState = asRecord(resultPayload?.workflowState)
const hasWorkflowState = !!resultPayload?.workflowState
logger.info('[SSE] edit_workflow result received', {
hasWorkflowState,
blockCount: hasWorkflowState
? Object.keys((workflowState as any).blocks ?? {}).length
: 0,
edgeCount:
hasWorkflowState && Array.isArray((workflowState as any).edges)
? (workflowState as any).edges.length
: 0,
blockCount: hasWorkflowState ? Object.keys(workflowState.blocks ?? {}).length : 0,
edgeCount: Array.isArray(workflowState.edges) ? workflowState.edges.length : 0,
})
if (workflowState) {
if (hasWorkflowState) {
const diffStore = useWorkflowDiffStore.getState()
diffStore.setProposedChanges(workflowState).catch((err) => {
logger.error('[SSE] Failed to apply workflow edit diff', {
error: err instanceof Error ? err.message : String(err),
toolName: current.name,
diffStore
.setProposedChanges(resultPayload.workflowState as WorkflowState)
.catch((err) => {
logger.error('[SSE] Failed to apply edit_workflow diff', {
error: err instanceof Error ? err.message : String(err),
})
})
})
}
} catch (err) {
logger.error('[SSE] workflow edit result handling failed', {
logger.error('[SSE] edit_workflow result handling failed', {
error: err instanceof Error ? err.message : String(err),
toolName: current.name,
})
}
}
@@ -596,23 +460,16 @@ export const sseHandlers: Record<string, SSEHandler> = {
: failedDependency || skipped
? ClientToolCallState.rejected
: ClientToolCallState.error
const paramsForBlock =
b.toolCall?.id === toolCallId
? paramsForCurrentToolCall || b.toolCall?.params
: b.toolCall?.params
context.contentBlocks[i] = {
...b,
toolCall: {
...b.toolCall,
params: paramsForBlock,
ui: uiMetadata || b.toolCall?.ui,
execution: executionMetadata || b.toolCall?.execution,
state: targetState,
display: resolveToolDisplay(
b.toolCall?.name,
targetState,
toolCallId,
paramsForBlock
b.toolCall?.params
),
},
}
@@ -630,9 +487,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
try {
const errorData = asRecord(data?.data)
const toolCallId: string | undefined =
data?.toolCallId ||
(errorData.id as string | undefined) ||
(errorData.callId as string | undefined)
data?.toolCallId || (errorData.id as string | undefined)
const failedDependency: boolean = data?.failedDependency === true
if (!toolCallId) return
const { toolCallsById } = get()
@@ -645,18 +500,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
) {
return
}
const targetState = errorData.state
? mapServerStateToClientState(errorData.state)
: failedDependency
? ClientToolCallState.rejected
: ClientToolCallState.error
const uiMetadata = extractToolUiMetadata(errorData)
const executionMetadata = extractToolExecutionMetadata(errorData)
const targetState = failedDependency
? ClientToolCallState.rejected
: ClientToolCallState.error
const updatedMap = { ...toolCallsById }
updatedMap[toolCallId] = {
...current,
ui: uiMetadata || current.ui,
execution: executionMetadata || current.execution,
state: targetState,
display: resolveToolDisplay(current.name, targetState, current.id, current.params),
}
@@ -671,19 +520,13 @@ export const sseHandlers: Record<string, SSEHandler> = {
isBackgroundState(b.toolCall?.state)
)
break
const targetState = errorData.state
? mapServerStateToClientState(errorData.state)
: failedDependency
? ClientToolCallState.rejected
: ClientToolCallState.error
const uiMetadata = extractToolUiMetadata(errorData)
const executionMetadata = extractToolExecutionMetadata(errorData)
const targetState = failedDependency
? ClientToolCallState.rejected
: ClientToolCallState.error
context.contentBlocks[i] = {
...b,
toolCall: {
...b.toolCall,
ui: uiMetadata || b.toolCall?.ui,
execution: executionMetadata || b.toolCall?.execution,
state: targetState,
display: resolveToolDisplay(
b.toolCall?.name,
@@ -704,26 +547,19 @@ export const sseHandlers: Record<string, SSEHandler> = {
}
},
tool_generating: (data, context, get, set) => {
const eventData = asRecord(data?.data)
const toolCallId =
data?.toolCallId ||
(eventData.id as string | undefined) ||
(eventData.callId as string | undefined)
const toolName =
data?.toolName ||
(eventData.name as string | undefined) ||
(eventData.toolName as string | undefined)
const { toolCallId, toolName } = data
if (!toolCallId || !toolName) return
const { toolCallsById } = get()
if (!toolCallsById[toolCallId]) {
const initialState = ClientToolCallState.generating
const isAutoAllowed = get().isToolAutoAllowed(toolName)
const initialState = isAutoAllowed
? ClientToolCallState.executing
: ClientToolCallState.pending
const tc: CopilotToolCall = {
id: toolCallId,
name: toolName,
state: initialState,
ui: extractToolUiMetadata(eventData),
execution: extractToolExecutionMetadata(eventData),
display: resolveToolDisplay(toolName, initialState, toolCallId),
}
const updated = { ...toolCallsById, [toolCallId]: tc }
@@ -736,27 +572,17 @@ export const sseHandlers: Record<string, SSEHandler> = {
},
tool_call: (data, context, get, set) => {
const toolData = asRecord(data?.data)
const id: string | undefined =
(toolData.id as string | undefined) ||
(toolData.callId as string | undefined) ||
data?.toolCallId
const name: string | undefined =
(toolData.name as string | undefined) ||
(toolData.toolName as string | undefined) ||
data?.toolName
const id: string | undefined = (toolData.id as string | undefined) || data?.toolCallId
const name: string | undefined = (toolData.name as string | undefined) || data?.toolName
if (!id) return
const args = toolData.arguments as Record<string, unknown> | undefined
const isPartial = toolData.partial === true
const uiMetadata = extractToolUiMetadata(toolData)
const executionMetadata = extractToolExecutionMetadata(toolData)
const serverState = toolData.state
const { toolCallsById } = get()
const existing = toolCallsById[id]
const toolName = name || existing?.name || 'unknown_tool'
let initialState = serverState
? mapServerStateToClientState(serverState)
: ClientToolCallState.pending
const isAutoAllowed = get().isToolAutoAllowed(toolName)
let initialState = isAutoAllowed ? ClientToolCallState.executing : ClientToolCallState.pending
// Avoid flickering back to pending on partial/duplicate events once a tool is executing.
if (
@@ -771,8 +597,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
...existing,
name: toolName,
state: initialState,
ui: uiMetadata || existing.ui,
execution: executionMetadata || existing.execution,
...(args ? { params: args } : {}),
display: resolveToolDisplay(toolName, initialState, id, args || existing.params),
}
@@ -780,8 +604,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
id,
name: toolName,
state: initialState,
ui: uiMetadata,
execution: executionMetadata,
...(args ? { params: args } : {}),
display: resolveToolDisplay(toolName, initialState, id, args),
}
@@ -796,12 +618,20 @@ export const sseHandlers: Record<string, SSEHandler> = {
return
}
const shouldInterrupt = next.ui?.showInterrupt === true
// Auto-allowed tools: send confirmation to the server so it can proceed
// without waiting for the user to click "Allow".
if (isAutoAllowed) {
sendAutoAcceptConfirmation(id)
}
// Client-run capability: execution is delegated to the browser.
// We run immediately only when no interrupt is required.
if (isClientRunCapability(next) && !shouldInterrupt) {
executeRunToolOnClient(id, toolName, args || next.params || {})
// Client-executable run tools: execute on the client for real-time feedback
// (block pulsing, console logs, stop button). The server defers execution
// for these tools in interactive mode; the client reports back via mark-complete.
if (
CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName) &&
initialState === ClientToolCallState.executing
) {
executeRunToolOnClient(id, toolName, args || existing?.params || {})
}
// OAuth: dispatch event to open the OAuth connect modal

View File

@@ -9,10 +9,9 @@ import type { SSEEvent } from '@/lib/copilot/orchestrator/types'
import { resolveToolDisplay } from '@/lib/copilot/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry'
import type { CopilotStore, CopilotToolCall } from '@/stores/panel/copilot/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
type SSEHandler,
sendAutoAcceptConfirmation,
sseHandlers,
updateStreamingMessage,
} from './handlers'
@@ -25,113 +24,6 @@ type StoreSet = (
partial: Partial<CopilotStore> | ((state: CopilotStore) => Partial<CopilotStore>)
) => void
function mapServerStateToClientState(state: unknown): ClientToolCallState {
switch (String(state || '')) {
case 'generating':
return ClientToolCallState.generating
case 'pending':
case 'awaiting_approval':
return ClientToolCallState.pending
case 'executing':
return ClientToolCallState.executing
case 'success':
return ClientToolCallState.success
case 'rejected':
case 'skipped':
return ClientToolCallState.rejected
case 'aborted':
return ClientToolCallState.aborted
case 'error':
case 'failed':
return ClientToolCallState.error
default:
return ClientToolCallState.pending
}
}
function extractToolUiMetadata(data: Record<string, unknown>): CopilotToolCall['ui'] | undefined {
const ui = asRecord(data.ui)
if (!ui || Object.keys(ui).length === 0) return undefined
const autoAllowedFromUi = ui.autoAllowed === true
const autoAllowedFromData = data.autoAllowed === true
return {
title: typeof ui.title === 'string' ? ui.title : undefined,
phaseLabel: typeof ui.phaseLabel === 'string' ? ui.phaseLabel : undefined,
icon: typeof ui.icon === 'string' ? ui.icon : undefined,
showInterrupt: ui.showInterrupt === true,
showRemember: ui.showRemember === true,
autoAllowed: autoAllowedFromUi || autoAllowedFromData,
actions: Array.isArray(ui.actions)
? ui.actions
.map((action) => {
const a = asRecord(action)
const id = typeof a.id === 'string' ? a.id : undefined
const label = typeof a.label === 'string' ? a.label : undefined
const kind: 'accept' | 'reject' = a.kind === 'reject' ? 'reject' : 'accept'
if (!id || !label) return null
return {
id,
label,
kind,
remember: a.remember === true,
}
})
.filter((a): a is NonNullable<typeof a> => !!a)
: undefined,
}
}
function extractToolExecutionMetadata(
data: Record<string, unknown>
): CopilotToolCall['execution'] | undefined {
const execution = asRecord(data.execution)
if (!execution || Object.keys(execution).length === 0) return undefined
return {
target: typeof execution.target === 'string' ? execution.target : undefined,
capabilityId: typeof execution.capabilityId === 'string' ? execution.capabilityId : undefined,
}
}
function isClientRunCapability(toolCall: CopilotToolCall): boolean {
if (toolCall.execution?.target === 'sim_client_capability') {
return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId
}
return CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)
}
function isWorkflowChangeApplyCall(toolCall: CopilotToolCall): boolean {
if (toolCall.name !== 'workflow_change') return false
const params = (toolCall.params || {}) as Record<string, unknown>
const mode = typeof params.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
return typeof params.proposalId === 'string' && params.proposalId.length > 0
}
function extractWorkflowStateFromResultPayload(
resultPayload: Record<string, unknown>
): WorkflowState | null {
const directState = asRecord(resultPayload.workflowState)
if (directState) return directState as unknown as WorkflowState
const editResult = asRecord(resultPayload.editResult)
const nestedState = asRecord(editResult?.workflowState)
if (nestedState) return nestedState as unknown as WorkflowState
return null
}
function extractOperationListFromResultPayload(
resultPayload: Record<string, unknown>
): Array<Record<string, unknown>> | undefined {
const operations = resultPayload.operations
if (Array.isArray(operations)) return operations as Array<Record<string, unknown>>
const compiled = resultPayload.compiledOperations
if (Array.isArray(compiled)) return compiled as Array<Record<string, unknown>>
return undefined
}
export function appendSubAgentContent(
context: ClientStreamingContext,
parentToolCallId: string,
@@ -272,8 +164,6 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
const name: string | undefined = (toolData.name as string | undefined) || data?.toolName
if (!id || !name) return
const isPartial = toolData.partial === true
const uiMetadata = extractToolUiMetadata(toolData)
const executionMetadata = extractToolExecutionMetadata(toolData)
let args: Record<string, unknown> | undefined = (toolData.arguments || toolData.input) as
| Record<string, unknown>
@@ -309,10 +199,9 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
const existingToolCall =
existingIndex >= 0 ? context.subAgentToolCalls[parentToolCallId][existingIndex] : undefined
const serverState = toolData.state
let initialState = serverState
? mapServerStateToClientState(serverState)
: ClientToolCallState.pending
// Auto-allowed tools skip pending state to avoid flashing interrupt buttons
const isAutoAllowed = get().isToolAutoAllowed(name)
let initialState = isAutoAllowed ? ClientToolCallState.executing : ClientToolCallState.pending
// Avoid flickering back to pending on partial/duplicate events once a tool is executing.
if (
@@ -326,8 +215,6 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
id,
name,
state: initialState,
ui: uiMetadata,
execution: executionMetadata,
...(args ? { params: args } : {}),
display: resolveToolDisplay(name, initialState, id, args),
}
@@ -354,11 +241,16 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
return
}
const shouldInterrupt = subAgentToolCall.ui?.showInterrupt === true
// Auto-allowed tools: send confirmation to the server so it can proceed
// without waiting for the user to click "Allow".
if (isAutoAllowed) {
sendAutoAcceptConfirmation(id)
}
// Client-run capability: execution is delegated to the browser.
// Execute immediately only for non-interrupting calls.
if (isClientRunCapability(subAgentToolCall) && !shouldInterrupt) {
// Client-executable run tools: if auto-allowed, execute immediately for
// real-time feedback. For non-auto-allowed, the user must click "Allow"
// first — handleRun in tool-call.tsx triggers executeRunToolOnClient.
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(name) && isAutoAllowed) {
executeRunToolOnClient(id, name, args || {})
}
},
@@ -383,45 +275,17 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
if (!context.subAgentToolCalls[parentToolCallId]) return
if (!context.subAgentBlocks[parentToolCallId]) return
const serverState = resultData.state
const targetState = serverState
? mapServerStateToClientState(serverState)
: success
? ClientToolCallState.success
: ClientToolCallState.error
const uiMetadata = extractToolUiMetadata(resultData)
const executionMetadata = extractToolExecutionMetadata(resultData)
const targetState = success ? ClientToolCallState.success : ClientToolCallState.error
const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex(
(tc: CopilotToolCall) => tc.id === toolCallId
)
if (existingIndex >= 0) {
const existing = context.subAgentToolCalls[parentToolCallId][existingIndex]
let nextParams = existing.params
const resultPayload = asRecord(
data?.result || resultData.result || resultData.data || data?.data
)
if (
targetState === ClientToolCallState.success &&
isWorkflowChangeApplyCall(existing) &&
resultPayload
) {
const operations = extractOperationListFromResultPayload(resultPayload)
if (operations && operations.length > 0) {
nextParams = {
...(existing.params || {}),
operations,
}
}
}
const updatedSubAgentToolCall = {
...existing,
params: nextParams,
ui: uiMetadata || existing.ui,
execution: executionMetadata || existing.execution,
state: targetState,
display: resolveToolDisplay(existing.name, targetState, toolCallId, nextParams),
display: resolveToolDisplay(existing.name, targetState, toolCallId, existing.params),
}
context.subAgentToolCalls[parentToolCallId][existingIndex] = updatedSubAgentToolCall
@@ -445,23 +309,6 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
state: targetState,
})
}
if (
targetState === ClientToolCallState.success &&
resultPayload &&
isWorkflowChangeApplyCall(updatedSubAgentToolCall)
) {
const workflowState = extractWorkflowStateFromResultPayload(resultPayload)
if (workflowState) {
const diffStore = useWorkflowDiffStore.getState()
diffStore.setProposedChanges(workflowState).catch((error) => {
logger.error('[SubAgent] Failed to apply workflow_change diff', {
error: error instanceof Error ? error.message : String(error),
toolCallId,
})
})
}
}
}
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)

View File

@@ -101,6 +101,9 @@ export const COPILOT_CHECKPOINTS_API_PATH = '/api/copilot/checkpoints'
/** POST — revert to a checkpoint. */
export const COPILOT_CHECKPOINTS_REVERT_API_PATH = '/api/copilot/checkpoints/revert'
/** GET/POST/DELETE — manage auto-allowed tools. */
export const COPILOT_AUTO_ALLOWED_TOOLS_API_PATH = '/api/copilot/auto-allowed-tools'
/** GET — fetch dynamically available copilot models. */
export const COPILOT_MODELS_API_PATH = '/api/copilot/models'

View File

@@ -0,0 +1,67 @@
export const INTERRUPT_TOOL_NAMES = [
'set_global_workflow_variables',
'run_workflow',
'run_workflow_until_block',
'run_from_block',
'run_block',
'manage_mcp_tool',
'manage_custom_tool',
'deploy_mcp',
'deploy_chat',
'deploy_api',
'create_workspace_mcp_server',
'set_environment_variables',
'make_api_request',
'oauth_request_access',
'navigate_ui',
'knowledge_base',
'generate_api_key',
] as const
export const INTERRUPT_TOOL_SET = new Set<string>(INTERRUPT_TOOL_NAMES)
export const SUBAGENT_TOOL_NAMES = [
'debug',
'edit',
'build',
'plan',
'test',
'deploy',
'auth',
'research',
'knowledge',
'custom_tool',
'tour',
'info',
'workflow',
'evaluate',
'superagent',
'discovery',
] as const
export const SUBAGENT_TOOL_SET = new Set<string>(SUBAGENT_TOOL_NAMES)
/**
* Respond tools are internal to the copilot's subagent system.
* They're used by subagents to signal completion and should NOT be executed by the sim side.
* The copilot backend handles these internally.
*/
export const RESPOND_TOOL_NAMES = [
'plan_respond',
'edit_respond',
'build_respond',
'debug_respond',
'info_respond',
'research_respond',
'deploy_respond',
'superagent_respond',
'discovery_respond',
'tour_respond',
'auth_respond',
'workflow_respond',
'knowledge_respond',
'custom_tool_respond',
'test_respond',
] as const
export const RESPOND_TOOL_SET = new Set<string>(RESPOND_TOOL_NAMES)

View File

@@ -1,12 +1,17 @@
import { createLogger } from '@sim/logger'
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
import { RESPOND_TOOL_SET, SUBAGENT_TOOL_SET } from '@/lib/copilot/orchestrator/config'
import {
asRecord,
getEventData,
markToolResultSeen,
wasToolResultSeen,
} from '@/lib/copilot/orchestrator/sse-utils'
import { markToolComplete } from '@/lib/copilot/orchestrator/tool-executor'
import {
isIntegrationTool,
isToolAvailableOnSimSide,
markToolComplete,
} from '@/lib/copilot/orchestrator/tool-executor'
import type {
ContentBlock,
ExecutionContext,
@@ -17,6 +22,7 @@ import type {
} from '@/lib/copilot/orchestrator/types'
import {
executeToolAndReport,
isInterruptToolName,
waitForToolCompletion,
waitForToolDecision,
} from './tool-execution'
@@ -35,113 +41,6 @@ const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
'run_block',
])
function mapServerStateToToolStatus(state: unknown): ToolCallState['status'] {
switch (String(state || '')) {
case 'generating':
case 'pending':
case 'awaiting_approval':
return 'pending'
case 'executing':
return 'executing'
case 'success':
return 'success'
case 'rejected':
case 'skipped':
return 'rejected'
case 'aborted':
return 'skipped'
case 'error':
case 'failed':
return 'error'
default:
return 'pending'
}
}
function getExecutionTarget(
toolData: Record<string, unknown>,
toolName: string
): { target: string; capabilityId?: string } {
const execution = asRecord(toolData.execution)
if (typeof execution.target === 'string' && execution.target.length > 0) {
return {
target: execution.target,
capabilityId:
typeof execution.capabilityId === 'string' ? execution.capabilityId : undefined,
}
}
// Fallback only when metadata is missing.
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
return { target: 'sim_client_capability', capabilityId: 'workflow.run' }
}
return { target: 'sim_server' }
}
function needsApproval(toolData: Record<string, unknown>): boolean {
const ui = asRecord(toolData.ui)
return ui.showInterrupt === true
}
async function waitForClientCapabilityAndReport(
toolCall: ToolCallState,
options: OrchestratorOptions,
logScope: string
): Promise<void> {
toolCall.status = 'executing'
const completion = await waitForToolCompletion(
toolCall.id,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
if (completion?.status === 'background') {
toolCall.status = 'skipped'
toolCall.endTime = Date.now()
markToolComplete(
toolCall.id,
toolCall.name,
202,
completion.message || 'Tool execution moved to background',
{ background: true }
).catch((err) => {
logger.error(`markToolComplete fire-and-forget failed (${logScope} background)`, {
toolCallId: toolCall.id,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCall.id)
return
}
if (completion?.status === 'rejected') {
toolCall.status = 'rejected'
toolCall.endTime = Date.now()
markToolComplete(toolCall.id, toolCall.name, 400, completion.message || 'Tool execution rejected')
.catch((err) => {
logger.error(`markToolComplete fire-and-forget failed (${logScope} rejected)`, {
toolCallId: toolCall.id,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCall.id)
return
}
const success = completion?.status === 'success'
toolCall.status = success ? 'success' : 'error'
toolCall.endTime = Date.now()
const msg = completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
logger.error(`markToolComplete fire-and-forget failed (${logScope})`, {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCall.id)
}
// Normalization + dedupe helpers live in sse-utils to keep server/client in sync.
function inferToolSuccess(data: Record<string, unknown> | undefined): {
@@ -186,11 +85,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
const { success, hasResultData, hasError } = inferToolSuccess(data)
current.status = data?.state
? mapServerStateToToolStatus(data.state)
: success
? 'success'
: 'error'
current.status = success ? 'success' : 'error'
current.endTime = Date.now()
if (hasResultData) {
current.result = {
@@ -209,7 +104,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
if (!toolCallId) return
const current = context.toolCalls.get(toolCallId)
if (!current) return
current.status = data?.state ? mapServerStateToToolStatus(data.state) : 'error'
current.status = 'error'
current.error = (data?.error as string | undefined) || 'Tool execution failed'
current.endTime = Date.now()
},
@@ -226,7 +121,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
context.toolCalls.set(toolCallId, {
id: toolCallId,
name: toolName,
status: data?.state ? mapServerStateToToolStatus(data.state) : 'pending',
status: 'pending',
startTime: Date.now(),
})
}
@@ -261,7 +156,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
context.toolCalls.set(toolCallId, {
id: toolCallId,
name: toolName,
status: toolData.state ? mapServerStateToToolStatus(toolData.state) : 'pending',
status: 'pending',
params: args,
startTime: Date.now(),
})
@@ -275,29 +170,83 @@ export const sseHandlers: Record<string, SSEHandler> = {
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall) return
const execution = getExecutionTarget(toolData, toolName)
const isInteractive = options.interactive === true
const requiresApproval = isInteractive && needsApproval(toolData)
if (toolData.state) {
toolCall.status = mapServerStateToToolStatus(toolData.state)
// Subagent tools are executed by the copilot backend, not sim side.
if (SUBAGENT_TOOL_SET.has(toolName)) {
return
}
if (requiresApproval) {
// Respond tools are internal to copilot's subagent system - skip execution.
// The copilot backend handles these internally to signal subagent completion.
if (RESPOND_TOOL_SET.has(toolName)) {
toolCall.status = 'success'
toolCall.endTime = Date.now()
toolCall.result = {
success: true,
output: 'Internal respond tool - handled by copilot backend',
}
return
}
const isInterruptTool = isInterruptToolName(toolName)
const isInteractive = options.interactive === true
// Integration tools (user-installed) also require approval in interactive mode
const needsApproval = isInterruptTool || isIntegrationTool(toolName)
if (needsApproval && isInteractive) {
const decision = await waitForToolDecision(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
if (decision?.status === 'accepted' || decision?.status === 'success') {
if (execution.target === 'sim_client_capability' && isInteractive) {
await waitForClientCapabilityAndReport(toolCall, options, 'run tool')
// Client-executable run tools: defer execution to the browser client.
// The client calls executeWorkflowWithFullLogging for real-time feedback
// (block pulsing, logs, stop button) and reports completion via
// /api/copilot/confirm with status success/error. We poll Redis for
// that completion signal, then fire-and-forget markToolComplete to Go.
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
toolCall.status = 'executing'
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
if (completion?.status === 'background') {
toolCall.status = 'skipped'
toolCall.endTime = Date.now()
markToolComplete(
toolCall.id,
toolCall.name,
202,
completion.message || 'Tool execution moved to background',
{ background: true }
).catch((err) => {
logger.error('markToolComplete fire-and-forget failed (run tool background)', {
toolCallId: toolCall.id,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCallId)
return
}
const success = completion?.status === 'success'
toolCall.status = success ? 'success' : 'error'
toolCall.endTime = Date.now()
const msg =
completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
// Fire-and-forget: tell Go backend the tool is done
// (must NOT await — see deadlock note in executeToolAndReport)
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
logger.error('markToolComplete fire-and-forget failed (run tool)', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCallId)
return
}
if (execution.target === 'sim_server' || execution.target === 'sim_client_capability') {
if (options.autoExecuteTools !== false) {
await executeToolAndReport(toolCallId, context, execContext, options)
}
}
await executeToolAndReport(toolCallId, context, execContext, options)
return
}
@@ -359,15 +308,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
return
}
if (execution.target === 'sim_client_capability' && isInteractive) {
await waitForClientCapabilityAndReport(toolCall, options, 'run tool')
return
}
if (
(execution.target === 'sim_server' || execution.target === 'sim_client_capability') &&
options.autoExecuteTools !== false
) {
if (options.autoExecuteTools !== false) {
await executeToolAndReport(toolCallId, context, execContext, options)
}
},
@@ -469,7 +410,7 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
const toolCall: ToolCallState = {
id: toolCallId,
name: toolName,
status: toolData.state ? mapServerStateToToolStatus(toolData.state) : 'pending',
status: 'pending',
params: args,
startTime: Date.now(),
}
@@ -487,26 +428,37 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
if (isPartial) return
const execution = getExecutionTarget(toolData, toolName)
const isInteractive = options.interactive === true
const requiresApproval = isInteractive && needsApproval(toolData)
// Respond tools are internal to copilot's subagent system - skip execution.
if (RESPOND_TOOL_SET.has(toolName)) {
toolCall.status = 'success'
toolCall.endTime = Date.now()
toolCall.result = {
success: true,
output: 'Internal respond tool - handled by copilot backend',
}
return
}
if (requiresApproval) {
// Tools that only exist on the Go backend (e.g. search_patterns,
// search_errors, remember_debug) should NOT be re-executed on the Sim side.
// The Go backend already executed them and will send its own tool_result
// SSE event with the real outcome. Trying to execute them here would fail
// with "Tool not found" and incorrectly mark the tool as failed.
if (!isToolAvailableOnSimSide(toolName)) {
return
}
// Interrupt tools and integration tools (user-installed) require approval
// in interactive mode, same as top-level handler.
const needsSubagentApproval = isInterruptToolName(toolName) || isIntegrationTool(toolName)
if (options.interactive === true && needsSubagentApproval) {
const decision = await waitForToolDecision(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
if (decision?.status === 'accepted' || decision?.status === 'success') {
if (execution.target === 'sim_client_capability' && isInteractive) {
await waitForClientCapabilityAndReport(toolCall, options, 'subagent run tool')
return
}
if (execution.target === 'sim_server' || execution.target === 'sim_client_capability') {
if (options.autoExecuteTools !== false) {
await executeToolAndReport(toolCallId, context, execContext, options)
}
}
await executeToolAndReport(toolCallId, context, execContext, options)
return
}
if (decision?.status === 'rejected' || decision?.status === 'error') {
@@ -565,15 +517,66 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
return
}
if (execution.target === 'sim_client_capability' && isInteractive) {
await waitForClientCapabilityAndReport(toolCall, options, 'subagent run tool')
// Client-executable run tools in interactive mode: defer to client.
// Same pattern as main handler: wait for client completion, then tell Go.
if (options.interactive === true && CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
toolCall.status = 'executing'
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
if (completion?.status === 'rejected') {
toolCall.status = 'rejected'
toolCall.endTime = Date.now()
markToolComplete(
toolCall.id,
toolCall.name,
400,
completion.message || 'Tool execution rejected'
).catch((err) => {
logger.error('markToolComplete fire-and-forget failed (subagent run tool rejected)', {
toolCallId: toolCall.id,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCallId)
return
}
if (completion?.status === 'background') {
toolCall.status = 'skipped'
toolCall.endTime = Date.now()
markToolComplete(
toolCall.id,
toolCall.name,
202,
completion.message || 'Tool execution moved to background',
{ background: true }
).catch((err) => {
logger.error('markToolComplete fire-and-forget failed (subagent run tool background)', {
toolCallId: toolCall.id,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCallId)
return
}
const success = completion?.status === 'success'
toolCall.status = success ? 'success' : 'error'
toolCall.endTime = Date.now()
const msg = completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
logger.error('markToolComplete fire-and-forget failed (subagent run tool)', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: err instanceof Error ? err.message : String(err),
})
})
markToolResultSeen(toolCallId)
return
}
if (
(execution.target === 'sim_server' || execution.target === 'sim_client_capability') &&
options.autoExecuteTools !== false
) {
if (options.autoExecuteTools !== false) {
await executeToolAndReport(toolCallId, context, execContext, options)
}
},
@@ -593,7 +596,7 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
const { success, hasResultData, hasError } = inferToolSuccess(data)
const status = data?.state ? mapServerStateToToolStatus(data.state) : success ? 'success' : 'error'
const status = success ? 'success' : 'error'
const endTime = Date.now()
const result = hasResultData ? { success, output: data?.result || data?.data } : undefined

View File

@@ -4,6 +4,7 @@ import {
TOOL_DECISION_MAX_POLL_MS,
TOOL_DECISION_POLL_BACKOFF,
} from '@/lib/copilot/constants'
import { INTERRUPT_TOOL_SET } from '@/lib/copilot/orchestrator/config'
import { getToolConfirmation } from '@/lib/copilot/orchestrator/persistence'
import {
asRecord,
@@ -20,6 +21,10 @@ import type {
const logger = createLogger('CopilotSseToolExecution')
export function isInterruptToolName(toolName: string): boolean {
return INTERRUPT_TOOL_SET.has(toolName)
}
export async function executeToolAndReport(
toolCallId: string,
context: StreamingContext,
@@ -29,11 +34,9 @@ export async function executeToolAndReport(
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall) return
const lockable = toolCall as typeof toolCall & { __simExecuting?: boolean }
if (lockable.__simExecuting) return
if (toolCall.status === 'executing') return
if (wasToolResultSeen(toolCall.id)) return
lockable.__simExecuting = true
toolCall.status = 'executing'
try {
const result = await executeToolServerSide(toolCall, execContext)
@@ -119,8 +122,6 @@ export async function executeToolAndReport(
},
}
await options?.onEvent?.(errorEvent)
} finally {
delete lockable.__simExecuting
}
}

View File

@@ -325,10 +325,6 @@ const SERVER_TOOLS = new Set<string>([
'get_block_config',
'get_trigger_blocks',
'edit_workflow',
'workflow_context_get',
'workflow_context_expand',
'workflow_change',
'workflow_verify',
'get_workflow_console',
'search_documentation',
'search_online',

View File

@@ -609,83 +609,6 @@ const META_edit_workflow: ToolMetadata = {
},
}
const META_workflow_change: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Planning workflow changes', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Applying workflow changes', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Updated your workflow', icon: Grid2x2Check },
[ClientToolCallState.error]: { text: 'Failed to update your workflow', icon: XCircle },
[ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 },
[ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: Grid2x2X },
[ClientToolCallState.aborted]: { text: 'Aborted workflow changes', icon: MinusCircle },
[ClientToolCallState.pending]: { text: 'Planning workflow changes', icon: Loader2 },
},
getDynamicText: (params, state) => {
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'dry_run') {
switch (state) {
case ClientToolCallState.success:
return 'Planned workflow changes'
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return 'Planning workflow changes'
}
}
if (mode === 'apply' || typeof params?.proposalId === 'string') {
switch (state) {
case ClientToolCallState.success:
return 'Applied workflow changes'
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return 'Applying workflow changes'
}
}
return undefined
},
uiConfig: {
isSpecial: true,
customRenderer: 'edit_summary',
},
}
const META_workflow_context_get: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Gathering workflow context', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Gathering workflow context', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Gathering workflow context', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Gathered workflow context', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to gather workflow context', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped workflow context', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow context', icon: MinusCircle },
},
}
const META_workflow_context_expand: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Expanding workflow schemas', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Expanding workflow schemas', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Expanding workflow schemas', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Expanded workflow schemas', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to expand workflow schemas', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped schema expansion', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted schema expansion', icon: MinusCircle },
},
}
const META_workflow_verify: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Verifying workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Verifying workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Verifying workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Verified workflow', icon: CheckCircle2 },
[ClientToolCallState.error]: { text: 'Workflow verification failed', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped workflow verification', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow verification', icon: MinusCircle },
},
}
const META_evaluate: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Evaluating', icon: Loader2 },
@@ -2619,10 +2542,6 @@ const TOOL_METADATA_BY_ID: Record<string, ToolMetadata> = {
deploy_mcp: META_deploy_mcp,
edit: META_edit,
edit_workflow: META_edit_workflow,
workflow_context_get: META_workflow_context_get,
workflow_context_expand: META_workflow_context_expand,
workflow_change: META_workflow_change,
workflow_verify: META_workflow_verify,
evaluate: META_evaluate,
get_block_config: META_get_block_config,
get_block_options: META_get_block_options,

View File

@@ -13,12 +13,6 @@ import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-cr
import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables'
import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow'
import { getWorkflowConsoleServerTool } from '@/lib/copilot/tools/server/workflow/get-workflow-console'
import { workflowChangeServerTool } from '@/lib/copilot/tools/server/workflow/workflow-change'
import {
workflowContextExpandServerTool,
workflowContextGetServerTool,
} from '@/lib/copilot/tools/server/workflow/workflow-context'
import { workflowVerifyServerTool } from '@/lib/copilot/tools/server/workflow/workflow-verify'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
export { ExecuteResponseSuccessSchema }
@@ -41,10 +35,6 @@ const serverToolRegistry: Record<string, BaseServerTool> = {
[getCredentialsServerTool.name]: getCredentialsServerTool,
[makeApiRequestServerTool.name]: makeApiRequestServerTool,
[knowledgeBaseServerTool.name]: knowledgeBaseServerTool,
[workflowContextGetServerTool.name]: workflowContextGetServerTool,
[workflowContextExpandServerTool.name]: workflowContextExpandServerTool,
[workflowChangeServerTool.name]: workflowChangeServerTool,
[workflowVerifyServerTool.name]: workflowVerifyServerTool,
}
/**

View File

@@ -1,93 +0,0 @@
import crypto from 'crypto'
type StoreEntry<T> = {
value: T
expiresAt: number
}
const DEFAULT_TTL_MS = 30 * 60 * 1000
const MAX_ENTRIES = 500
class TTLStore<T> {
private readonly data = new Map<string, StoreEntry<T>>()
constructor(private readonly ttlMs = DEFAULT_TTL_MS) {}
set(value: T): string {
this.gc()
if (this.data.size >= MAX_ENTRIES) {
const firstKey = this.data.keys().next().value as string | undefined
if (firstKey) {
this.data.delete(firstKey)
}
}
const id = crypto.randomUUID()
this.data.set(id, {
value,
expiresAt: Date.now() + this.ttlMs,
})
return id
}
get(id: string): T | null {
const entry = this.data.get(id)
if (!entry) return null
if (entry.expiresAt <= Date.now()) {
this.data.delete(id)
return null
}
return entry.value
}
private gc(): void {
const now = Date.now()
for (const [key, entry] of this.data.entries()) {
if (entry.expiresAt <= now) {
this.data.delete(key)
}
}
}
}
export type WorkflowContextPack = {
workflowId: string
snapshotHash: string
workflowState: {
blocks: Record<string, any>
edges: Array<Record<string, any>>
loops: Record<string, any>
parallels: Record<string, any>
}
schemasByType: Record<string, any>
schemaRefsByType: Record<string, string>
summary: Record<string, any>
}
export type WorkflowChangeProposal = {
workflowId: string
baseSnapshotHash: string
compiledOperations: Array<Record<string, any>>
diffSummary: Record<string, any>
warnings: string[]
diagnostics: string[]
touchedBlocks: string[]
}
const contextPackStore = new TTLStore<WorkflowContextPack>()
const proposalStore = new TTLStore<WorkflowChangeProposal>()
export function saveContextPack(pack: WorkflowContextPack): string {
return contextPackStore.set(pack)
}
export function getContextPack(id: string): WorkflowContextPack | null {
return contextPackStore.get(id)
}
export function saveProposal(proposal: WorkflowChangeProposal): string {
return proposalStore.set(proposal)
}
export function getProposal(id: string): WorkflowChangeProposal | null {
return proposalStore.get(id)
}

View File

@@ -1,987 +0,0 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { getBlock } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import {
getContextPack,
getProposal,
saveProposal,
type WorkflowChangeProposal,
} from './change-store'
import { editWorkflowServerTool } from './edit-workflow'
import { applyOperationsToWorkflowState } from './edit-workflow/engine'
import { preValidateCredentialInputs } from './edit-workflow/validation'
import { hashWorkflowState, loadWorkflowStateFromDb } from './workflow-state'
const logger = createLogger('WorkflowChangeServerTool')
const TargetSchema = z
.object({
blockId: z.string().optional(),
alias: z.string().optional(),
match: z
.object({
type: z.string().optional(),
name: z.string().optional(),
})
.optional(),
})
.strict()
const CredentialSelectionSchema = z
.object({
strategy: z.enum(['first_connected', 'by_id', 'by_name']).optional(),
id: z.string().optional(),
name: z.string().optional(),
})
.strict()
const ChangeOperationSchema = z
.object({
op: z.enum(['set', 'unset', 'merge', 'append', 'remove', 'attach_credential']),
path: z.string().optional(),
value: z.any().optional(),
provider: z.string().optional(),
selection: CredentialSelectionSchema.optional(),
required: z.boolean().optional(),
})
.strict()
const MutationSchema = z
.object({
action: z.enum([
'ensure_block',
'patch_block',
'remove_block',
'connect',
'disconnect',
'ensure_variable',
'set_variable',
]),
target: TargetSchema.optional(),
type: z.string().optional(),
name: z.string().optional(),
inputs: z.record(z.any()).optional(),
triggerMode: z.boolean().optional(),
advancedMode: z.boolean().optional(),
enabled: z.boolean().optional(),
changes: z.array(ChangeOperationSchema).optional(),
from: TargetSchema.optional(),
to: TargetSchema.optional(),
handle: z.string().optional(),
toHandle: z.string().optional(),
mode: z.enum(['set', 'append', 'remove']).optional(),
})
.strict()
const LinkEndpointSchema = z
.object({
blockId: z.string().optional(),
alias: z.string().optional(),
match: z
.object({
type: z.string().optional(),
name: z.string().optional(),
})
.optional(),
handle: z.string().optional(),
})
.strict()
const LinkSchema = z
.object({
from: LinkEndpointSchema,
to: LinkEndpointSchema,
mode: z.enum(['set', 'append', 'remove']).optional(),
})
.strict()
const ChangeSpecSchema = z
.object({
objective: z.string().optional(),
constraints: z.record(z.any()).optional(),
resources: z.record(z.any()).optional(),
mutations: z.array(MutationSchema).optional(),
links: z.array(LinkSchema).optional(),
acceptance: z.array(z.any()).optional(),
})
.strict()
const WorkflowChangeInputSchema = z
.object({
mode: z.enum(['dry_run', 'apply']),
workflowId: z.string().optional(),
contextPackId: z.string().optional(),
proposalId: z.string().optional(),
baseSnapshotHash: z.string().optional(),
expectedSnapshotHash: z.string().optional(),
changeSpec: ChangeSpecSchema.optional(),
})
.strict()
type WorkflowChangeParams = z.input<typeof WorkflowChangeInputSchema>
type ChangeSpec = z.input<typeof ChangeSpecSchema>
type TargetRef = z.input<typeof TargetSchema>
type ChangeOperation = z.input<typeof ChangeOperationSchema>
type CredentialRecord = {
id: string
name: string
provider: string
isDefault?: boolean
}
type ConnectionTarget = {
block: string
handle?: string
}
type ConnectionState = Map<string, Map<string, ConnectionTarget[]>>
function createDraftBlockId(seed?: string): string {
const suffix = crypto.randomUUID().slice(0, 8)
const base = seed ? seed.replace(/[^a-zA-Z0-9]/g, '').slice(0, 24) : 'draft'
return `${base || 'draft'}_${suffix}`
}
function normalizeHandle(handle?: string): string {
if (!handle) return 'source'
if (handle === 'success') return 'source'
return handle
}
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value))
}
function stableUnique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))]
}
function buildConnectionState(workflowState: {
edges: Array<Record<string, any>>
}): ConnectionState {
const state: ConnectionState = new Map()
for (const edge of workflowState.edges || []) {
const source = String(edge.source || '')
const target = String(edge.target || '')
if (!source || !target) continue
const sourceHandle = normalizeHandle(String(edge.sourceHandle || 'source'))
const targetHandle = edge.targetHandle ? String(edge.targetHandle) : undefined
let handleMap = state.get(source)
if (!handleMap) {
handleMap = new Map()
state.set(source, handleMap)
}
const existing = handleMap.get(sourceHandle) || []
existing.push({ block: target, handle: targetHandle })
handleMap.set(sourceHandle, existing)
}
return state
}
function connectionStateToPayload(state: Map<string, ConnectionTarget[]>): Record<string, any> {
const payload: Record<string, any> = {}
for (const [handle, targets] of state.entries()) {
if (!targets || targets.length === 0) continue
const normalizedTargets = targets.map((target) => {
if (!target.handle || target.handle === 'target') {
return target.block
}
return { block: target.block, handle: target.handle }
})
payload[handle] = normalizedTargets.length === 1 ? normalizedTargets[0] : normalizedTargets
}
return payload
}
function findMatchingBlockId(
workflowState: { blocks: Record<string, any> },
target: TargetRef
): string | null {
if (target.blockId && workflowState.blocks[target.blockId]) {
return target.blockId
}
if (target.match) {
const type = target.match.type
const name = target.match.name?.toLowerCase()
const matches = Object.entries(workflowState.blocks || {}).filter(([_, block]) => {
const blockType = String((block as Record<string, unknown>).type || '')
const blockName = String((block as Record<string, unknown>).name || '').toLowerCase()
const typeOk = type ? blockType === type : true
const nameOk = name ? blockName === name : true
return typeOk && nameOk
})
if (matches.length === 1) {
return matches[0][0]
}
if (matches.length > 1) {
throw new Error(
`ambiguous_target: target match resolved to ${matches.length} blocks (${matches.map(([id]) => id).join(', ')})`
)
}
}
return null
}
function getNestedValue(value: any, path: string[]): any {
let cursor = value
for (const segment of path) {
if (cursor == null || typeof cursor !== 'object') return undefined
cursor = cursor[segment]
}
return cursor
}
function setNestedValue(base: any, path: string[], nextValue: any): any {
if (path.length === 0) return nextValue
const out = Array.isArray(base) ? [...base] : { ...(base || {}) }
let cursor: any = out
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
const current = cursor[key]
cursor[key] =
current && typeof current === 'object'
? Array.isArray(current)
? [...current]
: { ...current }
: {}
cursor = cursor[key]
}
cursor[path[path.length - 1]] = nextValue
return out
}
function removeArrayItem(arr: unknown[], value: unknown): unknown[] {
return arr.filter((item) => JSON.stringify(item) !== JSON.stringify(value))
}
function selectCredentialId(
availableCredentials: CredentialRecord[],
provider: string,
selection: z.infer<typeof CredentialSelectionSchema> | undefined
): string | null {
const providerLower = provider.toLowerCase()
const providerMatches = availableCredentials.filter((credential) => {
const credentialProvider = credential.provider.toLowerCase()
return (
credentialProvider === providerLower || credentialProvider.startsWith(`${providerLower}-`)
)
})
const pool = providerMatches.length > 0 ? providerMatches : availableCredentials
const strategy = selection?.strategy || 'first_connected'
if (strategy === 'by_id') {
const id = selection?.id
if (!id) return null
return pool.find((credential) => credential.id === id)?.id || null
}
if (strategy === 'by_name') {
const name = selection?.name?.toLowerCase()
if (!name) return null
const exact = pool.find((credential) => credential.name.toLowerCase() === name)
if (exact) return exact.id
const partial = pool.find((credential) => credential.name.toLowerCase().includes(name))
return partial?.id || null
}
const defaultCredential = pool.find((credential) => credential.isDefault)
if (defaultCredential) return defaultCredential.id
return pool[0]?.id || null
}
function selectCredentialFieldId(blockType: string, provider: string): string | null {
const blockConfig = getBlock(blockType)
if (!blockConfig) return null
const oauthFields = (blockConfig.subBlocks || []).filter(
(subBlock) => subBlock.type === 'oauth-input'
)
if (oauthFields.length === 0) return null
const providerKey = provider.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()
const fieldMatch = oauthFields.find((subBlock) =>
subBlock.id
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase()
.includes(providerKey)
)
if (fieldMatch) return fieldMatch.id
return oauthFields[0].id
}
function ensureConnectionTarget(
existing: ConnectionTarget[],
target: ConnectionTarget,
mode: 'set' | 'append' | 'remove'
): ConnectionTarget[] {
if (mode === 'set') {
return [target]
}
if (mode === 'remove') {
return existing.filter(
(item) =>
!(item.block === target.block && (item.handle || 'target') === (target.handle || 'target'))
)
}
const duplicate = existing.some(
(item) =>
item.block === target.block && (item.handle || 'target') === (target.handle || 'target')
)
if (duplicate) return existing
return [...existing, target]
}
async function compileChangeSpec(params: {
changeSpec: ChangeSpec
workflowState: {
blocks: Record<string, any>
edges: Array<Record<string, any>>
loops: Record<string, any>
parallels: Record<string, any>
}
userId: string
workflowId: string
}): Promise<{
operations: Array<Record<string, any>>
warnings: string[]
diagnostics: string[]
touchedBlocks: string[]
}> {
const { changeSpec, workflowState, userId, workflowId } = params
const operations: Array<Record<string, any>> = []
const diagnostics: string[] = []
const warnings: string[] = []
const touchedBlocks = new Set<string>()
const aliasMap = new Map<string, string>()
const workingState = deepClone(workflowState)
const connectionState = buildConnectionState(workingState)
const connectionTouchedSources = new Set<string>()
const plannedBlockTypes = new Map<string, string>()
// Seed aliases from existing block names.
for (const [blockId, block] of Object.entries(workingState.blocks || {})) {
const blockName = String((block as Record<string, unknown>).name || '')
if (!blockName) continue
const normalizedAlias = blockName.replace(/[^a-zA-Z0-9]/g, '')
if (normalizedAlias && !aliasMap.has(normalizedAlias)) {
aliasMap.set(normalizedAlias, blockId)
}
}
const credentialsResponse = await getCredentialsServerTool.execute({ workflowId }, { userId })
const availableCredentials: CredentialRecord[] =
credentialsResponse?.oauth?.connected?.credentials?.map((credential: any) => ({
id: String(credential.id || ''),
name: String(credential.name || ''),
provider: String(credential.provider || ''),
isDefault: Boolean(credential.isDefault),
})) || []
const resolveTarget = (
target: TargetRef | undefined,
allowCreateAlias = false
): string | null => {
if (!target) return null
if (target.blockId) {
if (workingState.blocks[target.blockId] || plannedBlockTypes.has(target.blockId)) {
return target.blockId
}
return allowCreateAlias ? target.blockId : null
}
if (target.alias) {
if (aliasMap.has(target.alias)) return aliasMap.get(target.alias) || null
const byMatch = findMatchingBlockId(workingState, { alias: target.alias })
if (byMatch) {
aliasMap.set(target.alias, byMatch)
return byMatch
}
return allowCreateAlias ? target.alias : null
}
const matched = findMatchingBlockId(workingState, target)
if (matched) return matched
return null
}
const applyPatchChange = (
targetId: string,
blockType: string | null,
change: ChangeOperation,
paramsOut: Record<string, any>
): void => {
if (change.op === 'attach_credential') {
const provider = change.provider
if (!provider) {
diagnostics.push(`attach_credential on ${targetId} is missing provider`)
return
}
if (!blockType) {
diagnostics.push(`attach_credential on ${targetId} failed: unknown block type`)
return
}
const credentialFieldId = selectCredentialFieldId(blockType, provider)
if (!credentialFieldId) {
const msg = `No oauth input field found for block type "${blockType}" on ${targetId}`
if (change.required) diagnostics.push(msg)
else warnings.push(msg)
return
}
const credentialId = selectCredentialId(availableCredentials, provider, change.selection)
if (!credentialId) {
const msg = `No credential found for provider "${provider}" on ${targetId}`
if (change.required) diagnostics.push(msg)
else warnings.push(msg)
return
}
paramsOut.inputs = paramsOut.inputs || {}
paramsOut.inputs[credentialFieldId] = credentialId
return
}
if (!change.path) {
diagnostics.push(`${change.op} on ${targetId} requires a path`)
return
}
const pathSegments = change.path.split('.').filter(Boolean)
if (pathSegments.length === 0) {
diagnostics.push(`${change.op} on ${targetId} has an invalid path "${change.path}"`)
return
}
if (pathSegments[0] === 'inputs') {
const inputKey = pathSegments[1]
if (!inputKey) {
diagnostics.push(`${change.op} on ${targetId} has invalid input path "${change.path}"`)
return
}
const currentInputValue =
paramsOut.inputs?.[inputKey] ??
workingState.blocks[targetId]?.subBlocks?.[inputKey]?.value ??
null
let nextInputValue = currentInputValue
const nestedPath = pathSegments.slice(2)
if (change.op === 'set') {
nextInputValue =
nestedPath.length > 0
? setNestedValue(currentInputValue ?? {}, nestedPath, change.value)
: change.value
} else if (change.op === 'unset') {
nextInputValue =
nestedPath.length > 0 ? setNestedValue(currentInputValue ?? {}, nestedPath, null) : null
} else if (change.op === 'merge') {
if (nestedPath.length > 0) {
const baseObject = getNestedValue(currentInputValue ?? {}, nestedPath) || {}
if (
baseObject &&
typeof baseObject === 'object' &&
change.value &&
typeof change.value === 'object'
) {
nextInputValue = setNestedValue(currentInputValue ?? {}, nestedPath, {
...baseObject,
...(change.value as Record<string, unknown>),
})
} else {
diagnostics.push(`merge on ${targetId} at "${change.path}" requires object values`)
return
}
} else if (
currentInputValue &&
typeof currentInputValue === 'object' &&
!Array.isArray(currentInputValue) &&
change.value &&
typeof change.value === 'object' &&
!Array.isArray(change.value)
) {
nextInputValue = { ...currentInputValue, ...(change.value as Record<string, unknown>) }
} else if (currentInputValue == null && change.value && typeof change.value === 'object') {
nextInputValue = change.value
} else {
diagnostics.push(`merge on ${targetId} at "${change.path}" requires object values`)
return
}
} else if (change.op === 'append') {
const arr = Array.isArray(currentInputValue) ? [...currentInputValue] : []
arr.push(change.value)
nextInputValue = arr
} else if (change.op === 'remove') {
if (!Array.isArray(currentInputValue)) {
diagnostics.push(`remove on ${targetId} at "${change.path}" requires an array value`)
return
}
nextInputValue = removeArrayItem(currentInputValue, change.value)
}
paramsOut.inputs = paramsOut.inputs || {}
paramsOut.inputs[inputKey] = nextInputValue
return
}
if (pathSegments.length !== 1) {
diagnostics.push(
`Unsupported path "${change.path}" on ${targetId}. Use inputs.* or top-level field names.`
)
return
}
const topLevelField = pathSegments[0]
if (!['name', 'type', 'triggerMode', 'advancedMode', 'enabled'].includes(topLevelField)) {
diagnostics.push(`Unsupported top-level path "${change.path}" on ${targetId}`)
return
}
paramsOut[topLevelField] = change.op === 'unset' ? null : change.value
}
for (const mutation of changeSpec.mutations || []) {
if (mutation.action === 'ensure_block') {
const targetId = resolveTarget(mutation.target, true)
if (!targetId) {
diagnostics.push('ensure_block is missing a resolvable target')
continue
}
const existingBlock = workingState.blocks[targetId]
if (existingBlock) {
const editParams: Record<string, any> = {}
if (mutation.name) editParams.name = mutation.name
if (mutation.type) editParams.type = mutation.type
if (mutation.inputs) editParams.inputs = mutation.inputs
if (mutation.triggerMode !== undefined) editParams.triggerMode = mutation.triggerMode
if (mutation.advancedMode !== undefined) editParams.advancedMode = mutation.advancedMode
if (mutation.enabled !== undefined) editParams.enabled = mutation.enabled
operations.push({
operation_type: 'edit',
block_id: targetId,
params: editParams,
})
touchedBlocks.add(targetId)
} else {
if (!mutation.type || !mutation.name) {
diagnostics.push(`ensure_block for "${targetId}" requires type and name when creating`)
continue
}
const blockId =
mutation.target?.blockId || mutation.target?.alias || createDraftBlockId(mutation.name)
const addParams: Record<string, any> = {
type: mutation.type,
name: mutation.name,
}
if (mutation.inputs) addParams.inputs = mutation.inputs
if (mutation.triggerMode !== undefined) addParams.triggerMode = mutation.triggerMode
if (mutation.advancedMode !== undefined) addParams.advancedMode = mutation.advancedMode
if (mutation.enabled !== undefined) addParams.enabled = mutation.enabled
operations.push({
operation_type: 'add',
block_id: blockId,
params: addParams,
})
workingState.blocks[blockId] = {
id: blockId,
type: mutation.type,
name: mutation.name,
subBlocks: Object.fromEntries(
Object.entries(mutation.inputs || {}).map(([key, value]) => [
key,
{ id: key, value, type: 'short-input' },
])
),
triggerMode: mutation.triggerMode || false,
advancedMode: mutation.advancedMode || false,
enabled: mutation.enabled !== undefined ? mutation.enabled : true,
}
plannedBlockTypes.set(blockId, mutation.type)
touchedBlocks.add(blockId)
if (mutation.target?.alias) aliasMap.set(mutation.target.alias, blockId)
}
continue
}
if (mutation.action === 'patch_block') {
const targetId = resolveTarget(mutation.target)
if (!targetId) {
diagnostics.push('patch_block target could not be resolved')
continue
}
const blockType =
String(workingState.blocks[targetId]?.type || '') || plannedBlockTypes.get(targetId) || null
const editParams: Record<string, any> = {}
for (const change of mutation.changes || []) {
applyPatchChange(targetId, blockType, change, editParams)
}
if (Object.keys(editParams).length === 0) {
warnings.push(`patch_block for ${targetId} had no effective changes`)
continue
}
operations.push({
operation_type: 'edit',
block_id: targetId,
params: editParams,
})
touchedBlocks.add(targetId)
continue
}
if (mutation.action === 'remove_block') {
const targetId = resolveTarget(mutation.target)
if (!targetId) {
diagnostics.push('remove_block target could not be resolved')
continue
}
operations.push({
operation_type: 'delete',
block_id: targetId,
params: {},
})
touchedBlocks.add(targetId)
connectionState.delete(targetId)
for (const [source, handles] of connectionState.entries()) {
for (const [handle, targets] of handles.entries()) {
const nextTargets = targets.filter((target) => target.block !== targetId)
handles.set(handle, nextTargets)
}
connectionTouchedSources.add(source)
}
continue
}
if (mutation.action === 'connect' || mutation.action === 'disconnect') {
const from = resolveTarget(mutation.from)
const to = resolveTarget(mutation.to)
if (!from || !to) {
diagnostics.push(`${mutation.action} requires resolvable from/to targets`)
continue
}
const sourceHandle = normalizeHandle(mutation.handle)
const targetHandle = mutation.toHandle || 'target'
let sourceMap = connectionState.get(from)
if (!sourceMap) {
sourceMap = new Map()
connectionState.set(from, sourceMap)
}
const existingTargets = sourceMap.get(sourceHandle) || []
const mode = mutation.action === 'disconnect' ? 'remove' : mutation.mode || 'set'
const nextTargets = ensureConnectionTarget(
existingTargets,
{ block: to, handle: targetHandle },
mode
)
sourceMap.set(sourceHandle, nextTargets)
connectionTouchedSources.add(from)
touchedBlocks.add(from)
}
}
for (const link of changeSpec.links || []) {
const from = resolveTarget(
{
blockId: link.from.blockId,
alias: link.from.alias,
match: link.from.match,
},
true
)
const to = resolveTarget(
{
blockId: link.to.blockId,
alias: link.to.alias,
match: link.to.match,
},
true
)
if (!from || !to) {
diagnostics.push('link contains unresolved from/to target')
continue
}
const sourceHandle = normalizeHandle(link.from.handle)
const targetHandle = link.to.handle || 'target'
let sourceMap = connectionState.get(from)
if (!sourceMap) {
sourceMap = new Map()
connectionState.set(from, sourceMap)
}
const existingTargets = sourceMap.get(sourceHandle) || []
const nextTargets = ensureConnectionTarget(
existingTargets,
{ block: to, handle: targetHandle },
link.mode || 'set'
)
sourceMap.set(sourceHandle, nextTargets)
connectionTouchedSources.add(from)
touchedBlocks.add(from)
}
for (const sourceBlockId of stableUnique([...connectionTouchedSources])) {
if (!connectionState.has(sourceBlockId)) continue
const sourceConnections = connectionState.get(sourceBlockId)!
operations.push({
operation_type: 'edit',
block_id: sourceBlockId,
params: {
connections: connectionStateToPayload(sourceConnections),
},
})
}
return {
operations,
warnings,
diagnostics,
touchedBlocks: [...touchedBlocks],
}
}
function summarizeDiff(
beforeState: { blocks: Record<string, any>; edges: Array<Record<string, any>> },
afterState: { blocks: Record<string, any>; edges: Array<Record<string, any>> },
operations: Array<Record<string, any>>
): Record<string, any> {
const beforeBlocks = Object.keys(beforeState.blocks || {}).length
const afterBlocks = Object.keys(afterState.blocks || {}).length
const beforeEdges = (beforeState.edges || []).length
const afterEdges = (afterState.edges || []).length
const counts = operations.reduce<Record<string, number>>((acc, operation) => {
const opType = String(operation.operation_type || 'unknown')
acc[opType] = (acc[opType] || 0) + 1
return acc
}, {})
return {
operationCounts: counts,
blocks: {
before: beforeBlocks,
after: afterBlocks,
delta: afterBlocks - beforeBlocks,
},
edges: {
before: beforeEdges,
after: afterEdges,
delta: afterEdges - beforeEdges,
},
}
}
async function validateAndSimulateOperations(params: {
workflowState: {
blocks: Record<string, any>
edges: Array<Record<string, any>>
loops: Record<string, any>
parallels: Record<string, any>
}
operations: Array<Record<string, any>>
userId: string
}): Promise<{
operationsForApply: Array<Record<string, any>>
simulatedState: {
blocks: Record<string, any>
edges: Array<Record<string, any>>
loops: Record<string, any>
parallels: Record<string, any>
}
warnings: string[]
diagnostics: string[]
}> {
const diagnostics: string[] = []
const warnings: string[] = []
const permissionConfig = await getUserPermissionConfig(params.userId)
const { filteredOperations, errors: preValidationErrors } = await preValidateCredentialInputs(
params.operations as any,
{ userId: params.userId },
params.workflowState
)
for (const error of preValidationErrors) {
warnings.push(error.error)
}
const { state, validationErrors, skippedItems } = applyOperationsToWorkflowState(
params.workflowState,
filteredOperations as any,
permissionConfig
)
for (const validationError of validationErrors) {
warnings.push(validationError.error)
}
for (const skippedItem of skippedItems) {
warnings.push(skippedItem.reason)
}
if (Object.keys(state.blocks || {}).length === 0) {
diagnostics.push('Simulation produced an empty workflow state')
}
return {
operationsForApply: filteredOperations as Array<Record<string, any>>,
simulatedState: state,
warnings,
diagnostics,
}
}
export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any> = {
name: 'workflow_change',
inputSchema: WorkflowChangeInputSchema,
async execute(params: WorkflowChangeParams, context?: { userId: string }): Promise<any> {
if (!context?.userId) {
throw new Error('Unauthorized workflow access')
}
if (params.mode === 'dry_run') {
const workflowId = params.workflowId || getContextPack(params.contextPackId || '')?.workflowId
if (!workflowId) {
throw new Error('workflowId is required for dry_run')
}
if (!params.changeSpec) {
throw new Error('changeSpec is required for dry_run')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: context.userId,
action: 'write',
})
if (!authorization.allowed) {
throw new Error(authorization.message || 'Unauthorized workflow access')
}
const { workflowState } = await loadWorkflowStateFromDb(workflowId)
const currentHash = hashWorkflowState(workflowState as unknown as Record<string, unknown>)
const requestedHash = params.baseSnapshotHash
if (requestedHash && requestedHash !== currentHash) {
throw new Error(
`snapshot_mismatch: expected ${requestedHash} but current state is ${currentHash}`
)
}
const compileResult = await compileChangeSpec({
changeSpec: params.changeSpec,
workflowState,
userId: context.userId,
workflowId,
})
const simulation = await validateAndSimulateOperations({
workflowState,
operations: compileResult.operations,
userId: context.userId,
})
const diffSummary = summarizeDiff(
workflowState,
simulation.simulatedState,
simulation.operationsForApply
)
const diagnostics = [...compileResult.diagnostics, ...simulation.diagnostics]
const warnings = [...compileResult.warnings, ...simulation.warnings]
const proposal: WorkflowChangeProposal = {
workflowId,
baseSnapshotHash: currentHash,
compiledOperations: simulation.operationsForApply,
diffSummary,
warnings,
diagnostics,
touchedBlocks: compileResult.touchedBlocks,
}
const proposalId = saveProposal(proposal)
logger.info('Compiled workflow_change dry run', {
workflowId,
proposalId,
operationCount: proposal.compiledOperations.length,
warningCount: warnings.length,
diagnosticsCount: diagnostics.length,
})
return {
success: diagnostics.length === 0,
mode: 'dry_run',
workflowId,
proposalId,
baseSnapshotHash: currentHash,
compiledOperations: proposal.compiledOperations,
diffSummary,
warnings,
diagnostics,
touchedBlocks: proposal.touchedBlocks,
}
}
// apply mode
const proposalId = params.proposalId
if (!proposalId) {
throw new Error('proposalId is required for apply')
}
const proposal = getProposal(proposalId)
if (!proposal) {
throw new Error(`Proposal not found or expired: ${proposalId}`)
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: proposal.workflowId,
userId: context.userId,
action: 'write',
})
if (!authorization.allowed) {
throw new Error(authorization.message || 'Unauthorized workflow access')
}
const { workflowState } = await loadWorkflowStateFromDb(proposal.workflowId)
const currentHash = hashWorkflowState(workflowState as unknown as Record<string, unknown>)
const expectedHash = params.expectedSnapshotHash || proposal.baseSnapshotHash
if (expectedHash && expectedHash !== currentHash) {
throw new Error(`snapshot_mismatch: expected ${expectedHash} but current is ${currentHash}`)
}
const applyResult = await editWorkflowServerTool.execute(
{
workflowId: proposal.workflowId,
operations: proposal.compiledOperations as any,
},
{ userId: context.userId }
)
const appliedWorkflowState = (applyResult as any)?.workflowState
const newSnapshotHash = appliedWorkflowState
? hashWorkflowState(appliedWorkflowState as Record<string, unknown>)
: null
return {
success: true,
mode: 'apply',
workflowId: proposal.workflowId,
proposalId,
baseSnapshotHash: proposal.baseSnapshotHash,
newSnapshotHash,
operations: proposal.compiledOperations,
workflowState: appliedWorkflowState || null,
appliedDiff: proposal.diffSummary,
warnings: proposal.warnings,
diagnostics: proposal.diagnostics,
editResult: applyResult,
}
},
}

View File

@@ -1,158 +0,0 @@
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { getContextPack, saveContextPack } from './change-store'
import {
buildSchemasByType,
getAllKnownBlockTypes,
hashWorkflowState,
loadWorkflowStateFromDb,
summarizeWorkflowState,
} from './workflow-state'
const logger = createLogger('WorkflowContextServerTool')
const WorkflowContextGetInputSchema = z.object({
workflowId: z.string(),
objective: z.string().optional(),
includeBlockTypes: z.array(z.string()).optional(),
includeAllSchemas: z.boolean().optional(),
})
type WorkflowContextGetParams = z.infer<typeof WorkflowContextGetInputSchema>
const WorkflowContextExpandInputSchema = z.object({
contextPackId: z.string(),
blockTypes: z.array(z.string()).optional(),
schemaRefs: z.array(z.string()).optional(),
})
type WorkflowContextExpandParams = z.infer<typeof WorkflowContextExpandInputSchema>
function parseSchemaRefToBlockType(schemaRef: string): string | null {
if (!schemaRef) return null
const [blockType] = schemaRef.split('@')
return blockType || null
}
function buildAvailableBlockCatalog(
schemaRefsByType: Record<string, string>
): Array<Record<string, any>> {
return Object.entries(schemaRefsByType)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([blockType, schemaRef]) => ({
blockType,
schemaRef,
}))
}
export const workflowContextGetServerTool: BaseServerTool<WorkflowContextGetParams, any> = {
name: 'workflow_context_get',
inputSchema: WorkflowContextGetInputSchema,
async execute(params: WorkflowContextGetParams, context?: { userId: string }): Promise<any> {
if (!context?.userId) {
throw new Error('Unauthorized workflow access')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: params.workflowId,
userId: context.userId,
action: 'read',
})
if (!authorization.allowed) {
throw new Error(authorization.message || 'Unauthorized workflow access')
}
const { workflowState } = await loadWorkflowStateFromDb(params.workflowId)
const snapshotHash = hashWorkflowState(workflowState as unknown as Record<string, unknown>)
const blockTypesInWorkflow = Object.values(workflowState.blocks || {}).map((block: any) =>
String(block?.type || '')
)
const requestedTypes = params.includeBlockTypes || []
const includeAllSchemas = params.includeAllSchemas === true
const candidateTypes = includeAllSchemas
? getAllKnownBlockTypes()
: [...blockTypesInWorkflow, ...requestedTypes]
const { schemasByType, schemaRefsByType } = buildSchemasByType(candidateTypes)
const summary = summarizeWorkflowState(workflowState)
const packId = saveContextPack({
workflowId: params.workflowId,
snapshotHash,
workflowState,
schemasByType,
schemaRefsByType,
summary: {
...summary,
objective: params.objective || null,
},
})
logger.info('Generated workflow context pack', {
workflowId: params.workflowId,
contextPackId: packId,
schemaCount: Object.keys(schemaRefsByType).length,
})
return {
success: true,
contextPackId: packId,
workflowId: params.workflowId,
snapshotHash,
summary: {
...summary,
objective: params.objective || null,
},
schemaRefsByType,
availableBlockCatalog: buildAvailableBlockCatalog(schemaRefsByType),
inScopeSchemas: schemasByType,
}
},
}
export const workflowContextExpandServerTool: BaseServerTool<WorkflowContextExpandParams, any> = {
name: 'workflow_context_expand',
inputSchema: WorkflowContextExpandInputSchema,
async execute(params: WorkflowContextExpandParams, context?: { userId: string }): Promise<any> {
if (!context?.userId) {
throw new Error('Unauthorized workflow access')
}
const contextPack = getContextPack(params.contextPackId)
if (!contextPack) {
throw new Error(`Context pack not found or expired: ${params.contextPackId}`)
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: contextPack.workflowId,
userId: context.userId,
action: 'read',
})
if (!authorization.allowed) {
throw new Error(authorization.message || 'Unauthorized workflow access')
}
const requestedBlockTypes = new Set<string>()
for (const blockType of params.blockTypes || []) {
if (blockType) requestedBlockTypes.add(blockType)
}
for (const schemaRef of params.schemaRefs || []) {
const blockType = parseSchemaRefToBlockType(schemaRef)
if (blockType) requestedBlockTypes.add(blockType)
}
const typesToExpand = [...requestedBlockTypes]
const { schemasByType, schemaRefsByType } = buildSchemasByType(typesToExpand)
return {
success: true,
contextPackId: params.contextPackId,
workflowId: contextPack.workflowId,
snapshotHash: contextPack.snapshotHash,
schemasByType,
schemaRefsByType,
}
},
}

View File

@@ -1,226 +0,0 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getAllBlockTypes, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
const logger = createLogger('WorkflowContextState')
function stableSortValue(value: any): any {
if (Array.isArray(value)) {
return value.map(stableSortValue)
}
if (value && typeof value === 'object') {
const sorted: Record<string, any> = {}
for (const key of Object.keys(value).sort()) {
sorted[key] = stableSortValue(value[key])
}
return sorted
}
return value
}
export function hashWorkflowState(state: Record<string, unknown>): string {
const stable = stableSortValue(state)
const payload = JSON.stringify(stable)
return `sha256:${crypto.createHash('sha256').update(payload).digest('hex')}`
}
function normalizeOptions(options: unknown): string[] | null {
if (!Array.isArray(options)) return null
const normalized = options
.map((option) => {
if (option == null) return null
if (typeof option === 'object') {
const optionRecord = option as Record<string, unknown>
const id = optionRecord.id
if (typeof id === 'string') return id
const label = optionRecord.label
if (typeof label === 'string') return label
return null
}
return String(option)
})
.filter((value): value is string => Boolean(value))
return normalized.length > 0 ? normalized : null
}
function serializeRequired(required: SubBlockConfig['required']): boolean | Record<string, any> {
if (typeof required === 'boolean') return required
if (!required) return false
if (typeof required === 'object') {
const out: Record<string, any> = {}
const record = required as Record<string, unknown>
for (const key of ['field', 'operator', 'value']) {
if (record[key] !== undefined) {
out[key] = record[key]
}
}
return out
}
return false
}
function serializeSubBlock(subBlock: SubBlockConfig): Record<string, unknown> {
const staticOptions =
typeof subBlock.options === 'function' ? null : normalizeOptions(subBlock.options)
return {
id: subBlock.id,
type: subBlock.type,
title: subBlock.title,
description: subBlock.description || null,
mode: subBlock.mode || null,
placeholder: subBlock.placeholder || null,
hidden: Boolean(subBlock.hidden),
multiSelect: Boolean(subBlock.multiSelect),
required: serializeRequired(subBlock.required),
hasDynamicOptions: typeof subBlock.options === 'function',
options: staticOptions,
defaultValue: subBlock.defaultValue ?? null,
min: subBlock.min ?? null,
max: subBlock.max ?? null,
}
}
function serializeBlockSchema(blockType: string): Record<string, unknown> | null {
const blockConfig = getBlock(blockType)
if (!blockConfig) return null
const subBlocks = Array.isArray(blockConfig.subBlocks)
? blockConfig.subBlocks.map(serializeSubBlock)
: []
const outputs = blockConfig.outputs || {}
const outputKeys = Object.keys(outputs)
return {
blockType,
blockName: blockConfig.name || blockType,
category: blockConfig.category,
triggerAllowed: Boolean(blockConfig.triggerAllowed || blockConfig.triggers?.enabled),
hasTriggersConfig: Boolean(blockConfig.triggers?.enabled),
subBlocks,
outputKeys,
longDescription: blockConfig.longDescription || null,
}
}
export function buildSchemasByType(blockTypes: string[]): {
schemasByType: Record<string, any>
schemaRefsByType: Record<string, string>
} {
const schemasByType: Record<string, any> = {}
const schemaRefsByType: Record<string, string> = {}
const uniqueTypes = [...new Set(blockTypes.filter(Boolean))]
for (const blockType of uniqueTypes) {
const schema = serializeBlockSchema(blockType)
if (!schema) continue
const stableSchema = stableSortValue(schema)
const schemaHash = crypto
.createHash('sha256')
.update(JSON.stringify(stableSchema))
.digest('hex')
schemasByType[blockType] = stableSchema
schemaRefsByType[blockType] = `${blockType}@sha256:${schemaHash}`
}
return { schemasByType, schemaRefsByType }
}
export async function loadWorkflowStateFromDb(workflowId: string): Promise<{
workflowState: {
blocks: Record<string, any>
edges: Array<Record<string, any>>
loops: Record<string, any>
parallels: Record<string, any>
}
workspaceId?: string
}> {
const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
throw new Error(`Workflow ${workflowId} not found`)
}
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) {
throw new Error(`Workflow ${workflowId} has no normalized data`)
}
const blocks = { ...normalized.blocks }
const invalidBlockIds: string[] = []
for (const [blockId, block] of Object.entries(blocks)) {
if (!(block as { type?: unknown })?.type) {
invalidBlockIds.push(blockId)
}
}
for (const blockId of invalidBlockIds) {
delete blocks[blockId]
}
const invalidSet = new Set(invalidBlockIds)
const edges = (normalized.edges || []).filter(
(edge: any) => !invalidSet.has(edge.source) && !invalidSet.has(edge.target)
)
if (invalidBlockIds.length > 0) {
logger.warn('Dropped blocks without type while loading workflow state', {
workflowId,
dropped: invalidBlockIds,
})
}
return {
workflowState: {
blocks,
edges,
loops: normalized.loops || {},
parallels: normalized.parallels || {},
},
workspaceId: workflowRecord.workspaceId || undefined,
}
}
export function summarizeWorkflowState(workflowState: {
blocks: Record<string, any>
edges: Array<Record<string, any>>
loops: Record<string, any>
parallels: Record<string, any>
}): Record<string, unknown> {
const blocks = workflowState.blocks || {}
const edges = workflowState.edges || []
const blockTypes: Record<string, number> = {}
const triggerBlocks: Array<{ id: string; name: string; type: string }> = []
for (const [blockId, block] of Object.entries(blocks)) {
const blockType = String((block as Record<string, unknown>).type || 'unknown')
blockTypes[blockType] = (blockTypes[blockType] || 0) + 1
if ((block as Record<string, unknown>).triggerMode === true) {
triggerBlocks.push({
id: blockId,
name: String((block as Record<string, unknown>).name || blockType),
type: blockType,
})
}
}
return {
blockCount: Object.keys(blocks).length,
edgeCount: edges.length,
loopCount: Object.keys(workflowState.loops || {}).length,
parallelCount: Object.keys(workflowState.parallels || {}).length,
blockTypes,
triggerBlocks,
}
}
export function getAllKnownBlockTypes(): string[] {
return getAllBlockTypes()
}

View File

@@ -1,194 +0,0 @@
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { hashWorkflowState, loadWorkflowStateFromDb } from './workflow-state'
const logger = createLogger('WorkflowVerifyServerTool')
const AcceptanceItemSchema = z.union([
z.string(),
z.object({
kind: z.string().optional(),
assert: z.string(),
}),
])
const WorkflowVerifyInputSchema = z
.object({
workflowId: z.string(),
acceptance: z.array(AcceptanceItemSchema).optional(),
baseSnapshotHash: z.string().optional(),
})
.strict()
type WorkflowVerifyParams = z.infer<typeof WorkflowVerifyInputSchema>
function normalizeName(value: string): string {
return value.trim().toLowerCase()
}
function resolveBlockToken(
workflowState: { blocks: Record<string, any> },
token: string
): string | null {
if (!token) return null
if (workflowState.blocks[token]) return token
const normalized = normalizeName(token)
for (const [blockId, block] of Object.entries(workflowState.blocks || {})) {
const blockName = normalizeName(String((block as Record<string, unknown>).name || ''))
if (blockName === normalized) return blockId
}
return null
}
function hasPath(
workflowState: { edges: Array<Record<string, any>> },
blockPath: string[]
): boolean {
if (blockPath.length < 2) return true
const adjacency = new Map<string, string[]>()
for (const edge of workflowState.edges || []) {
const source = String(edge.source || '')
const target = String(edge.target || '')
if (!source || !target) continue
const existing = adjacency.get(source) || []
existing.push(target)
adjacency.set(source, existing)
}
for (let i = 0; i < blockPath.length - 1; i++) {
const from = blockPath[i]
const to = blockPath[i + 1]
const next = adjacency.get(from) || []
if (!next.includes(to)) return false
}
return true
}
function evaluateAssertions(params: {
workflowState: {
blocks: Record<string, any>
edges: Array<Record<string, any>>
}
assertions: string[]
}): { failures: string[]; checks: Array<Record<string, any>> } {
const failures: string[] = []
const checks: Array<Record<string, any>> = []
for (const assertion of params.assertions) {
if (assertion.startsWith('block_exists:')) {
const token = assertion.slice('block_exists:'.length).trim()
const blockId = resolveBlockToken(params.workflowState, token)
const passed = Boolean(blockId)
checks.push({ assert: assertion, passed, resolvedBlockId: blockId || null })
if (!passed) failures.push(`Assertion failed: ${assertion}`)
continue
}
if (assertion.startsWith('trigger_exists:')) {
const triggerType = normalizeName(assertion.slice('trigger_exists:'.length))
const triggerBlock = Object.values(params.workflowState.blocks || {}).find((block: any) => {
if (block?.triggerMode !== true) return false
return normalizeName(String(block?.type || '')) === triggerType
})
const passed = Boolean(triggerBlock)
checks.push({ assert: assertion, passed })
if (!passed) failures.push(`Assertion failed: ${assertion}`)
continue
}
if (assertion.startsWith('path_exists:')) {
const rawPath = assertion.slice('path_exists:'.length).trim()
const tokens = rawPath
.split('->')
.map((token) => token.trim())
.filter(Boolean)
const resolvedPath = tokens
.map((token) => resolveBlockToken(params.workflowState, token))
.filter((value): value is string => Boolean(value))
const resolvedAll = resolvedPath.length === tokens.length
const passed = resolvedAll && hasPath(params.workflowState, resolvedPath)
checks.push({
assert: assertion,
passed,
resolvedPath,
})
if (!passed) failures.push(`Assertion failed: ${assertion}`)
continue
}
// Unknown assertion format - mark as warning failure for explicit visibility.
checks.push({ assert: assertion, passed: false, reason: 'unknown_assertion_type' })
failures.push(`Unknown assertion format: ${assertion}`)
}
return { failures, checks }
}
export const workflowVerifyServerTool: BaseServerTool<WorkflowVerifyParams, any> = {
name: 'workflow_verify',
inputSchema: WorkflowVerifyInputSchema,
async execute(params: WorkflowVerifyParams, context?: { userId: string }): Promise<any> {
if (!context?.userId) {
throw new Error('Unauthorized workflow access')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: params.workflowId,
userId: context.userId,
action: 'read',
})
if (!authorization.allowed) {
throw new Error(authorization.message || 'Unauthorized workflow access')
}
const { workflowState } = await loadWorkflowStateFromDb(params.workflowId)
const snapshotHash = hashWorkflowState(workflowState as unknown as Record<string, unknown>)
if (params.baseSnapshotHash && params.baseSnapshotHash !== snapshotHash) {
return {
success: false,
verified: false,
reason: 'snapshot_mismatch',
expected: params.baseSnapshotHash,
current: snapshotHash,
}
}
const validation = validateWorkflowState(workflowState as any, { sanitize: false })
const assertions = (params.acceptance || []).map((item) =>
typeof item === 'string' ? item : item.assert
)
const assertionResults = evaluateAssertions({
workflowState,
assertions,
})
const verified =
validation.valid && assertionResults.failures.length === 0 && validation.errors.length === 0
logger.info('Workflow verification complete', {
workflowId: params.workflowId,
verified,
errorCount: validation.errors.length,
warningCount: validation.warnings.length,
assertionFailures: assertionResults.failures.length,
})
return {
success: true,
verified,
snapshotHash,
validation: {
valid: validation.valid,
errors: validation.errors,
warnings: validation.warnings,
},
assertions: assertionResults.checks,
failures: assertionResults.failures,
}
},
}

View File

@@ -312,6 +312,12 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'read:attachment:confluence',
'write:attachment:confluence',
'search:confluence',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:me',
'offline_access',
],
@@ -368,6 +374,14 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'read:comment.property:jira',
'read:jql:jira',
'read:field:jira',
// Project management (components, versions)
'manage:jira-project',
// Jira Software / Agile scopes (no classic equivalent)
'read:board-scope:jira-software',
'write:board-scope:jira-software',
'read:sprint:jira-software',
'write:sprint:jira-software',
'delete:sprint:jira-software',
// Jira Service Management scopes
'read:servicedesk:jira-service-management',
'read:requesttype:jira-service-management',
@@ -397,6 +411,16 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'write:request.participant:jira-service-management',
'read:request.approval:jira-service-management',
'write:request.approval:jira-service-management',
'read:request.feedback:jira-service-management',
'write:request.feedback:jira-service-management',
'delete:request.feedback:jira-service-management',
'read:request.notification:jira-service-management',
'write:request.notification:jira-service-management',
'delete:request.notification:jira-service-management',
'read:request.attachment:jira-service-management',
'read:knowledgebase:jira-service-management',
'delete:organization:jira-service-management',
'delete:servicedesk.customer:jira-service-management',
],
},
},

View File

@@ -27,9 +27,11 @@ import {
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { executeWebhookJob } from '@/background/webhook-execution'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { isConfluenceEventMatch } from '@/triggers/confluence/utils'
import { isGitHubEventMatch } from '@/triggers/github/utils'
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
import { isJiraEventMatch } from '@/triggers/jira/utils'
import { isJsmEventMatch } from '@/triggers/jsm/utils'
const logger = createLogger('WebhookProcessor')
@@ -681,7 +683,7 @@ export async function verifyProviderAuth(
}
}
if (foundWebhook.provider === 'jira') {
if (foundWebhook.provider === 'jira' || foundWebhook.provider === 'jira_service_management') {
const secret = providerConfig.secret as string | undefined
if (secret) {
@@ -706,6 +708,31 @@ export async function verifyProviderAuth(
}
}
if (foundWebhook.provider === 'confluence') {
const secret = providerConfig.secret as string | undefined
if (secret) {
const signature = request.headers.get('X-Hub-Signature')
if (!signature) {
logger.warn(`[${requestId}] Confluence webhook missing signature header`)
return new NextResponse('Unauthorized - Missing Confluence signature', { status: 401 })
}
const isValidSignature = validateJiraSignature(secret, signature, rawBody)
if (!isValidSignature) {
logger.warn(`[${requestId}] Confluence signature verification failed`, {
signatureLength: signature.length,
secretLength: secret.length,
})
return new NextResponse('Unauthorized - Invalid Confluence signature', { status: 401 })
}
logger.debug(`[${requestId}] Confluence signature verified successfully`)
}
}
if (foundWebhook.provider === 'github') {
const secret = providerConfig.secret as string | undefined
@@ -929,6 +956,60 @@ export async function queueWebhookExecution(
}
}
// JSM event filtering for event-specific triggers
if (foundWebhook.provider === 'jira_service_management') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId && triggerId !== 'jsm_webhook') {
const webhookEvent = body.webhookEvent as string | undefined
if (!isJsmEventMatch(triggerId, webhookEvent || '', body)) {
logger.debug(
`[${options.requestId}] JSM event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`,
{
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
triggerId,
receivedEvent: webhookEvent,
}
)
// Return 200 OK to prevent Jira from retrying
return NextResponse.json({
message: 'Event type does not match trigger configuration. Ignoring.',
})
}
}
}
// Confluence event filtering for event-specific triggers
if (foundWebhook.provider === 'confluence') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId && triggerId !== 'confluence_webhook') {
const event = body.event as string | undefined
if (!isConfluenceEventMatch(triggerId, event || '')) {
logger.debug(
`[${options.requestId}] Confluence event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
{
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
triggerId,
receivedEvent: event,
}
)
// Return 200 OK to prevent Confluence from retrying
return NextResponse.json({
message: 'Event type does not match trigger configuration. Ignoring.',
})
}
}
}
if (foundWebhook.provider === 'hubspot') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined

View File

@@ -78,6 +78,7 @@ const PROVIDER_EXTRACTORS: Record<string, (body: any) => string | null> = {
hubspot: extractHubSpotIdentifier,
linear: extractLinearIdentifier,
jira: extractJiraIdentifier,
jira_service_management: extractJiraIdentifier,
'microsoft-teams': extractMicrosoftTeamsIdentifier,
airtable: extractAirtableIdentifier,
grain: extractGrainIdentifier,

View File

@@ -530,6 +530,9 @@ export async function validateTwilioSignature(
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
const SLACK_MAX_FILES = 15
const JIRA_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
const CONFLUENCE_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
/**
* Resolves the full file object from the Slack API when the event payload
* only contains a partial file (e.g. missing url_private due to file_access restrictions).
@@ -679,6 +682,169 @@ async function downloadSlackFiles(
return downloaded
}
/**
* Downloads a Jira attachment file using Basic auth (email + API token).
* Returns the file data in the format expected by WebhookAttachmentProcessor.
*/
async function downloadJiraAttachment(
attachment: { content?: string; filename?: string; mimeType?: string; size?: number },
apiEmail: string,
apiToken: string
): Promise<{ name: string; data: string; mimeType: string; size: number } | null> {
const contentUrl = attachment.content
if (!contentUrl) {
logger.warn('Jira attachment has no content URL, skipping download')
return null
}
const reportedSize = Number(attachment.size) || 0
if (reportedSize > JIRA_MAX_FILE_SIZE) {
logger.warn('Jira attachment exceeds size limit, skipping', {
filename: attachment.filename,
size: reportedSize,
limit: JIRA_MAX_FILE_SIZE,
})
return null
}
try {
const urlValidation = await validateUrlWithDNS(contentUrl, 'attachment_content')
if (!urlValidation.isValid) {
logger.warn('Jira attachment URL failed DNS validation, skipping', {
filename: attachment.filename,
error: urlValidation.error,
})
return null
}
const authHeader = Buffer.from(`${apiEmail}:${apiToken}`).toString('base64')
const response = await secureFetchWithPinnedIP(contentUrl, urlValidation.resolvedIP!, {
headers: {
Authorization: `Basic ${authHeader}`,
Accept: '*/*',
},
})
if (!response.ok) {
logger.warn('Failed to download Jira attachment', {
filename: attachment.filename,
status: response.status,
})
return null
}
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
if (buffer.length > JIRA_MAX_FILE_SIZE) {
logger.warn('Downloaded Jira attachment exceeds size limit, skipping', {
filename: attachment.filename,
actualSize: buffer.length,
limit: JIRA_MAX_FILE_SIZE,
})
return null
}
return {
name: attachment.filename || 'attachment',
data: buffer.toString('base64'),
mimeType: attachment.mimeType || 'application/octet-stream',
size: buffer.length,
}
} catch (error) {
logger.error('Error downloading Jira attachment', {
filename: attachment.filename,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
/**
* Downloads a Confluence attachment file using Atlassian Basic Auth.
* Constructs the download URL from the domain and attachment download path.
*/
async function downloadConfluenceAttachment(
attachment: Record<string, any>,
domain: string,
apiEmail: string,
apiToken: string
): Promise<{ name: string; data: string; mimeType: string; size: number } | null> {
// Confluence webhook payload includes _links.download for the attachment
const downloadPath = attachment?._links?.download || attachment?._expandable?.download || null
const attachmentId = attachment?.id
if (!downloadPath && !attachmentId) {
logger.warn('Confluence attachment has no download path or ID, skipping download')
return null
}
const reportedSize = Number(attachment?.extensions?.fileSize || attachment?.fileSize || 0)
if (reportedSize > CONFLUENCE_MAX_FILE_SIZE) {
logger.warn('Confluence attachment exceeds size limit, skipping', {
title: attachment?.title,
size: reportedSize,
limit: CONFLUENCE_MAX_FILE_SIZE,
})
return null
}
// Build the download URL
const cleanDomain = domain.replace(/\/+$/, '')
const baseUrl = cleanDomain.startsWith('http') ? cleanDomain : `https://${cleanDomain}`
const downloadUrl = downloadPath
? `${baseUrl}/wiki${downloadPath}`
: `${baseUrl}/wiki/rest/api/content/${attachmentId}/download`
try {
const authHeader = Buffer.from(`${apiEmail}:${apiToken}`).toString('base64')
const response = await fetch(downloadUrl, {
headers: {
Authorization: `Basic ${authHeader}`,
Accept: '*/*',
'X-Atlassian-Token': 'no-check',
},
})
if (!response.ok) {
logger.warn('Failed to download Confluence attachment', {
title: attachment?.title,
status: response.status,
url: sanitizeUrlForLog(downloadUrl),
})
return null
}
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
if (buffer.length > CONFLUENCE_MAX_FILE_SIZE) {
logger.warn('Downloaded Confluence attachment exceeds size limit, skipping', {
title: attachment?.title,
actualSize: buffer.length,
limit: CONFLUENCE_MAX_FILE_SIZE,
})
return null
}
return {
name: attachment?.title || 'attachment',
data: buffer.toString('base64'),
mimeType:
attachment?.extensions?.mediaType || attachment?.mediaType || 'application/octet-stream',
size: buffer.length,
}
} catch (error) {
logger.error('Error downloading Confluence attachment', {
title: attachment?.title,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
/**
* Format webhook input based on provider
*/
@@ -1103,22 +1269,156 @@ export async function formatWebhookInput(
}
if (foundWebhook.provider === 'jira') {
const { extractIssueData, extractCommentData, extractWorklogData } = await import(
'@/triggers/jira/utils'
)
const {
extractIssueData,
extractCommentData,
extractWorklogData,
extractAttachmentData,
extractSprintData,
extractProjectData,
extractVersionData,
extractBoardData,
extractIssueLinkData,
} = await import('@/triggers/jira/utils')
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId === 'jira_issue_commented') {
if (
triggerId === 'jira_issue_commented' ||
triggerId === 'jira_comment_updated' ||
triggerId === 'jira_comment_deleted'
) {
return extractCommentData(body)
}
if (triggerId === 'jira_worklog_created') {
if (
triggerId === 'jira_worklog_created' ||
triggerId === 'jira_worklog_updated' ||
triggerId === 'jira_worklog_deleted'
) {
return extractWorklogData(body)
}
if (triggerId === 'jira_attachment_created' || triggerId === 'jira_attachment_deleted') {
const result = extractAttachmentData(body)
// Download the attachment file if configured
if (triggerId === 'jira_attachment_created') {
const apiEmail = providerConfig.apiEmail as string | undefined
const apiToken = providerConfig.apiToken as string | undefined
const includeAttachments = Boolean(providerConfig.includeAttachments)
if (includeAttachments && apiEmail && apiToken && result.attachment?.content) {
const downloaded = await downloadJiraAttachment(result.attachment, apiEmail, apiToken)
if (downloaded) {
result.attachments = [downloaded]
}
} else if (includeAttachments && (!apiEmail || !apiToken)) {
logger.warn(
'Jira attachment trigger has includeAttachments enabled but missing API credentials'
)
}
}
return result
}
if (triggerId?.startsWith('jira_sprint_')) {
return extractSprintData(body)
}
if (triggerId?.startsWith('jira_project_')) {
return extractProjectData(body)
}
if (triggerId?.startsWith('jira_version_')) {
return extractVersionData(body)
}
if (triggerId?.startsWith('jira_board_')) {
return extractBoardData(body)
}
if (triggerId?.startsWith('jira_issuelink_')) {
return extractIssueLinkData(body)
}
return extractIssueData(body)
}
if (foundWebhook.provider === 'jira_service_management') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined
const includeFiles = Boolean(providerConfig.includeFiles)
const jiraEmail = providerConfig.jiraEmail as string | undefined
const jiraApiToken = providerConfig.jiraApiToken as string | undefined
const webhookEvent = body.webhookEvent || ''
// Base data common to all JSM events
const baseData: Record<string, any> = {
webhookEvent,
timestamp: body.timestamp,
issue: body.issue || {},
}
// Handle attachment events
if (
triggerId === 'jsm_attachment_created' ||
triggerId === 'jsm_attachment_deleted' ||
webhookEvent.includes('attachment')
) {
const attachment = body.attachment || {}
baseData.attachment = attachment
let files: Array<{ name: string; data: string; mimeType: string; size: number }> = []
if (
webhookEvent.includes('attachment_created') &&
includeFiles &&
jiraEmail &&
jiraApiToken &&
attachment.content
) {
const downloaded = await downloadJiraAttachment(attachment, jiraEmail, jiraApiToken)
if (downloaded) {
files = [downloaded]
}
} else if (
webhookEvent.includes('attachment_created') &&
includeFiles &&
(!jiraEmail || !jiraApiToken)
) {
logger.warn(
'JSM attachment trigger has includeFiles enabled but missing Jira API credentials'
)
}
baseData.files = files
return baseData
}
// Handle comment events
if (
triggerId === 'jsm_request_commented' ||
triggerId === 'jsm_comment_updated' ||
triggerId === 'jsm_comment_deleted' ||
webhookEvent.includes('comment')
) {
baseData.comment = body.comment || {}
return baseData
}
// Handle worklog events
if (
triggerId === 'jsm_worklog_created' ||
triggerId === 'jsm_worklog_updated' ||
triggerId === 'jsm_worklog_deleted' ||
webhookEvent.includes('worklog')
) {
baseData.worklog = body.worklog || {}
return baseData
}
// Default: request events (created/updated/deleted) and generic webhook
baseData.issue_event_type_name = body.issue_event_type_name
baseData.changelog = body.changelog
return baseData
}
if (foundWebhook.provider === 'stripe') {
return body
}
@@ -1167,6 +1467,70 @@ export async function formatWebhookInput(
}
}
if (foundWebhook.provider === 'confluence') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const event = body.event as string | undefined
const result: Record<string, unknown> = {
event: event || '',
timestamp: body.timestamp,
userAccountId: body.userAccountId || '',
}
if (body.page) {
result.page = body.page
}
if (body.comment) {
result.comment = body.comment
}
if (body.blog || body.blogpost) {
result.blog = body.blog || body.blogpost
}
if (body.attachment) {
result.attachment = body.attachment
}
if (body.space) {
result.space = body.space
}
if (body.label) {
result.label = body.label
}
if (body.content) {
result.content = body.content
}
// Download attachment file content when configured
const includeFileContent = Boolean(providerConfig.includeFileContent)
const confluenceEmail = providerConfig.confluenceEmail as string | undefined
const confluenceApiToken = providerConfig.confluenceApiToken as string | undefined
const confluenceDomain = providerConfig.confluenceDomain as string | undefined
if (body.attachment && includeFileContent) {
if (confluenceEmail && confluenceApiToken && confluenceDomain) {
const downloaded = await downloadConfluenceAttachment(
body.attachment,
confluenceDomain,
confluenceEmail,
confluenceApiToken
)
if (downloaded) {
result.files = [downloaded]
}
} else {
logger.warn(
'Confluence attachment trigger has includeFileContent enabled but missing credentials (email, API token, or domain)'
)
}
}
return result
}
return body
}

View File

@@ -2364,6 +2364,261 @@ describe('hasWorkflowChanged', () => {
})
})
describe('Trigger Config Normalization (False Positive Prevention)', () => {
it.concurrent(
'should not detect change when deployed has null fields but current has values from triggerConfig',
() => {
// Core scenario: deployed state has null individual fields, current state has
// values populated from triggerConfig at runtime by populateTriggerFieldsFromConfig
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
botToken: { id: 'botToken', type: 'short-input', value: null },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123', botToken: 'token456' },
},
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' },
botToken: { id: 'botToken', type: 'short-input', value: 'token456' },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123', botToken: 'token456' },
},
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
it.concurrent(
'should detect change when user edits a trigger field to a different value',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'old-secret' },
},
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'new-secret' },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'old-secret' },
},
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
}
)
it.concurrent('should not detect change when both sides have no triggerConfig', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent(
'should not detect change when deployed has empty fields and triggerConfig populates them',
() => {
// Empty string is also treated as "empty" by normalizeTriggerConfigValues
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: '' },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
it.concurrent('should not detect change when triggerId differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerId: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerId: { value: 'slack_webhook' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent(
'should not detect change for namespaced system subBlock IDs like samplePayload_slack_webhook',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
samplePayload_slack_webhook: { value: 'old payload' },
triggerInstructions_slack_webhook: { value: 'old instructions' },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
samplePayload_slack_webhook: { value: 'new payload' },
triggerInstructions_slack_webhook: { value: 'new instructions' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
it.concurrent(
'should handle mixed scenario: some fields from triggerConfig, some user-edited',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
botToken: { id: 'botToken', type: 'short-input', value: null },
includeFiles: { id: 'includeFiles', type: 'switch', value: false },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123', botToken: 'token456' },
},
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' },
botToken: { id: 'botToken', type: 'short-input', value: 'token456' },
includeFiles: { id: 'includeFiles', type: 'switch', value: true },
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123', botToken: 'token456' },
},
},
}),
},
})
// includeFiles changed from false to true — this IS a real change
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
}
)
})
describe('Trigger Runtime Metadata (Should Not Trigger Change)', () => {
it.concurrent('should not detect change when webhookId differs', () => {
const deployedState = createWorkflowState({

View File

@@ -9,6 +9,7 @@ import {
normalizeLoop,
normalizeParallel,
normalizeSubBlockValue,
normalizeTriggerConfigValues,
normalizeValue,
normalizeVariables,
sanitizeVariable,
@@ -172,14 +173,18 @@ export function generateWorkflowDiffSummary(
}
}
// 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(currentSubBlocks), ...Object.keys(previousSubBlocks)]),
...new Set([...Object.keys(normalizedCurrentSubs), ...Object.keys(normalizedPreviousSubs)]),
])
for (const subId of allSubBlockIds) {
const currentSub = currentSubBlocks[subId] as Record<string, unknown> | undefined
const previousSub = previousSubBlocks[subId] as Record<string, unknown> | undefined
const currentSub = normalizedCurrentSubs[subId] as Record<string, unknown> | undefined
const previousSub = normalizedPreviousSubs[subId] as Record<string, unknown> | undefined
if (!currentSub || !previousSub) {
changes.push({

View File

@@ -4,10 +4,12 @@
import { describe, expect, it } from 'vitest'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
import {
filterSubBlockIds,
normalizedStringify,
normalizeEdge,
normalizeLoop,
normalizeParallel,
normalizeTriggerConfigValues,
normalizeValue,
sanitizeInputFormat,
sanitizeTools,
@@ -584,4 +586,214 @@ describe('Workflow Normalization Utilities', () => {
expect(result2).toBe(result3)
})
})
describe('filterSubBlockIds', () => {
it.concurrent('should exclude exact SYSTEM_SUBBLOCK_IDS', () => {
const ids = ['signingSecret', 'samplePayload', 'triggerInstructions', 'botToken']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['botToken', 'signingSecret'])
})
it.concurrent('should exclude namespaced SYSTEM_SUBBLOCK_IDS (prefix matching)', () => {
const ids = [
'signingSecret',
'samplePayload_slack_webhook',
'triggerInstructions_slack_webhook',
'webhookUrlDisplay_slack_webhook',
'botToken',
]
const result = filterSubBlockIds(ids)
expect(result).toEqual(['botToken', 'signingSecret'])
})
it.concurrent('should exclude exact TRIGGER_RUNTIME_SUBBLOCK_IDS', () => {
const ids = ['webhookId', 'triggerPath', 'triggerConfig', 'triggerId', 'signingSecret']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['signingSecret'])
})
it.concurrent('should not exclude IDs that merely contain a system ID substring', () => {
const ids = ['mySamplePayload', 'notSamplePayload']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['mySamplePayload', 'notSamplePayload'])
})
it.concurrent('should return sorted results', () => {
const ids = ['zebra', 'alpha', 'middle']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['alpha', 'middle', 'zebra'])
})
it.concurrent('should handle empty array', () => {
expect(filterSubBlockIds([])).toEqual([])
})
it.concurrent('should handle all IDs being excluded', () => {
const ids = ['webhookId', 'triggerPath', 'samplePayload', 'triggerConfig']
const result = filterSubBlockIds(ids)
expect(result).toEqual([])
})
it.concurrent('should exclude setupScript and scheduleInfo namespaced variants', () => {
const ids = ['setupScript_google_sheets_row', 'scheduleInfo_cron_trigger', 'realField']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['realField'])
})
it.concurrent('should exclude triggerCredentials namespaced variants', () => {
const ids = ['triggerCredentials_slack_webhook', 'signingSecret']
const result = filterSubBlockIds(ids)
expect(result).toEqual(['signingSecret'])
})
})
describe('normalizeTriggerConfigValues', () => {
it.concurrent('should return subBlocks unchanged when no triggerConfig exists', () => {
const subBlocks = {
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' },
botToken: { id: 'botToken', type: 'short-input', value: 'token456' },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result).toEqual(subBlocks)
})
it.concurrent('should return subBlocks unchanged when triggerConfig value is null', () => {
const subBlocks = {
triggerConfig: { id: 'triggerConfig', type: 'short-input', value: null },
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result).toEqual(subBlocks)
})
it.concurrent(
'should return subBlocks unchanged when triggerConfig value is not an object',
() => {
const subBlocks = {
triggerConfig: { id: 'triggerConfig', type: 'short-input', value: 'string-value' },
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result).toEqual(subBlocks)
}
)
it.concurrent('should populate null individual fields from triggerConfig', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123', botToken: 'token456' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
botToken: { id: 'botToken', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
expect((result.botToken as Record<string, unknown>).value).toBe('token456')
})
it.concurrent('should populate undefined individual fields from triggerConfig', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: undefined },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
})
it.concurrent('should populate empty string individual fields from triggerConfig', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: '' },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
})
it.concurrent('should NOT overwrite existing non-empty individual field values', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'old-secret' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'user-edited-secret' },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe('user-edited-secret')
})
it.concurrent('should skip triggerConfig fields that are null/undefined', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: null, botToken: undefined },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
botToken: { id: 'botToken', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect((result.signingSecret as Record<string, unknown>).value).toBe(null)
expect((result.botToken as Record<string, unknown>).value).toBe(null)
})
it.concurrent('should skip fields from triggerConfig that have no matching subBlock', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { nonExistentField: 'value123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
const result = normalizeTriggerConfigValues(subBlocks)
expect(result.nonExistentField).toBeUndefined()
expect((result.signingSecret as Record<string, unknown>).value).toBe(null)
})
it.concurrent('should not mutate the original subBlocks object', () => {
const original = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
}
normalizeTriggerConfigValues(original)
expect((original.signingSecret as Record<string, unknown>).value).toBe(null)
})
it.concurrent('should preserve other subBlock properties when populating value', () => {
const subBlocks = {
triggerConfig: {
id: 'triggerConfig',
type: 'short-input',
value: { signingSecret: 'secret123' },
},
signingSecret: {
id: 'signingSecret',
type: 'short-input',
value: null,
placeholder: 'Enter signing secret',
},
}
const result = normalizeTriggerConfigValues(subBlocks)
const normalized = result.signingSecret as Record<string, unknown>
expect(normalized.value).toBe('secret123')
expect(normalized.id).toBe('signingSecret')
expect(normalized.type).toBe('short-input')
expect(normalized.placeholder).toBe('Enter signing secret')
})
})
})

View File

@@ -418,10 +418,48 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo
*/
export function filterSubBlockIds(subBlockIds: string[]): string[] {
return subBlockIds
.filter((id) => !SYSTEM_SUBBLOCK_IDS.includes(id) && !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id))
.filter((id) => {
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
return false
return true
})
.sort()
}
/**
* Normalizes trigger block subBlocks by populating null/empty individual fields
* from the triggerConfig aggregate subBlock. This compensates for the runtime
* population done by populateTriggerFieldsFromConfig, ensuring consistent
* comparison between client state (with populated values) and deployed state
* (with null values from DB).
*/
export function normalizeTriggerConfigValues(
subBlocks: Record<string, unknown>
): Record<string, unknown> {
const triggerConfigSub = subBlocks.triggerConfig as Record<string, unknown> | undefined
const triggerConfigValue = triggerConfigSub?.value
if (!triggerConfigValue || typeof triggerConfigValue !== 'object') {
return subBlocks
}
const result = { ...subBlocks }
for (const [fieldId, configValue] of Object.entries(
triggerConfigValue as Record<string, unknown>
)) {
if (configValue === null || configValue === undefined) continue
const existingSub = result[fieldId] as Record<string, unknown> | undefined
if (
existingSub &&
(existingSub.value === null || existingSub.value === undefined || existingSub.value === '')
) {
result[fieldId] = { ...existingSub, value: configValue }
}
}
return result
}
/**
* Normalizes a subBlock value with sanitization for specific subBlock types.
* Sanitizes: tools (removes isExpanded), inputFormat (removes collapsed)

View File

@@ -18,6 +18,7 @@ import {
import { flushStreamingUpdates, stopStreamingUpdates } from '@/lib/copilot/client-sse/handlers'
import type { ClientContentBlock, ClientStreamingContext } from '@/lib/copilot/client-sse/types'
import {
COPILOT_AUTO_ALLOWED_TOOLS_API_PATH,
COPILOT_CHAT_API_PATH,
COPILOT_CHAT_STREAM_API_PATH,
COPILOT_CHECKPOINTS_API_PATH,
@@ -83,15 +84,6 @@ function isPageUnloading(): boolean {
return _isPageUnloading
}
function isWorkflowEditToolCall(name?: string, params?: Record<string, unknown>): boolean {
if (name === 'edit_workflow') return true
if (name !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
return typeof params?.proposalId === 'string' && params.proposalId.length > 0
}
function readActiveStreamFromStorage(): CopilotStreamInfo | null {
if (typeof window === 'undefined') return null
try {
@@ -148,6 +140,41 @@ function updateActiveStreamEventId(
writeActiveStreamToStorage(next)
}
const AUTO_ALLOWED_TOOLS_STORAGE_KEY = 'copilot_auto_allowed_tools'
function readAutoAllowedToolsFromStorage(): string[] | null {
if (typeof window === 'undefined') return null
try {
const raw = window.localStorage.getItem(AUTO_ALLOWED_TOOLS_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return null
return parsed.filter((item): item is string => typeof item === 'string')
} catch (error) {
logger.warn('[AutoAllowedTools] Failed to read local cache', {
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
function writeAutoAllowedToolsToStorage(tools: string[]): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(AUTO_ALLOWED_TOOLS_STORAGE_KEY, JSON.stringify(tools))
} catch (error) {
logger.warn('[AutoAllowedTools] Failed to write local cache', {
error: error instanceof Error ? error.message : String(error),
})
}
}
function isToolAutoAllowedByList(toolId: string, autoAllowedTools: string[]): boolean {
if (!toolId) return false
const normalizedTarget = toolId.trim()
return autoAllowedTools.some((allowed) => allowed?.trim() === normalizedTarget)
}
/**
* Clear any lingering diff preview from a previous session.
* Called lazily when the store is first activated (setWorkflowId).
@@ -453,6 +480,11 @@ function prepareSendContext(
.catch((err) => {
logger.warn('[Copilot] Failed to load sensitive credential IDs', err)
})
get()
.loadAutoAllowedTools()
.catch((err) => {
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
})
let newMessages: CopilotMessage[]
if (revertState) {
@@ -1005,6 +1037,8 @@ async function resumeFromLiveStream(
return false
}
const cachedAutoAllowedTools = readAutoAllowedToolsFromStorage()
// Initial state (subset required for UI/streaming)
const initialState = {
mode: 'build' as const,
@@ -1039,6 +1073,8 @@ const initialState = {
streamingPlanContent: '',
toolCallsById: {} as Record<string, CopilotToolCall>,
suppressAutoSelect: false,
autoAllowedTools: cachedAutoAllowedTools ?? ([] as string[]),
autoAllowedToolsLoaded: cachedAutoAllowedTools !== null,
activeStream: null as CopilotStreamInfo | null,
messageQueue: [] as import('./types').QueuedMessage[],
suppressAbortContinueOption: false,
@@ -1077,6 +1113,8 @@ export const useCopilotStore = create<CopilotStore>()(
agentPrefetch: get().agentPrefetch,
availableModels: get().availableModels,
isLoadingModels: get().isLoadingModels,
autoAllowedTools: get().autoAllowedTools,
autoAllowedToolsLoaded: get().autoAllowedToolsLoaded,
})
},
@@ -1391,6 +1429,16 @@ export const useCopilotStore = create<CopilotStore>()(
// Send a message (streaming only)
sendMessage: async (message: string, options = {}) => {
if (!get().autoAllowedToolsLoaded) {
try {
await get().loadAutoAllowedTools()
} catch (error) {
logger.warn('[Copilot] Failed to preload auto-allowed tools before send', {
error: error instanceof Error ? error.message : String(error),
})
}
}
const prepared = prepareSendContext(get, set, message, options as SendMessageOptionsInput)
if (!prepared) return
@@ -1657,7 +1705,7 @@ export const useCopilotStore = create<CopilotStore>()(
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (isWorkflowEditToolCall(tn, b.toolCall?.params)) {
if (tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
@@ -1666,9 +1714,7 @@ export const useCopilotStore = create<CopilotStore>()(
}
// Fallback to map if not found in messages
if (!id) {
const candidates = Object.values(toolCallsById).filter((t) =>
isWorkflowEditToolCall(t.name, t.params)
)
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
}
@@ -2361,6 +2407,74 @@ export const useCopilotStore = create<CopilotStore>()(
}
},
loadAutoAllowedTools: async () => {
try {
logger.debug('[AutoAllowedTools] Loading from API...')
const res = await fetch(COPILOT_AUTO_ALLOWED_TOOLS_API_PATH)
logger.debug('[AutoAllowedTools] Load response', { status: res.status, ok: res.ok })
if (res.ok) {
const data = await res.json()
const tools = data.autoAllowedTools ?? []
set({ autoAllowedTools: tools, autoAllowedToolsLoaded: true })
writeAutoAllowedToolsToStorage(tools)
logger.debug('[AutoAllowedTools] Loaded successfully', { count: tools.length, tools })
} else {
set({ autoAllowedToolsLoaded: true })
logger.warn('[AutoAllowedTools] Load failed with status', { status: res.status })
}
} catch (err) {
set({ autoAllowedToolsLoaded: true })
logger.error('[AutoAllowedTools] Failed to load', { error: err })
}
},
addAutoAllowedTool: async (toolId: string) => {
try {
logger.debug('[AutoAllowedTools] Adding tool...', { toolId })
const res = await fetch(COPILOT_AUTO_ALLOWED_TOOLS_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolId }),
})
logger.debug('[AutoAllowedTools] API response', { toolId, status: res.status, ok: res.ok })
if (res.ok) {
const data = await res.json()
logger.debug('[AutoAllowedTools] API returned', { toolId, tools: data.autoAllowedTools })
const tools = data.autoAllowedTools ?? []
set({ autoAllowedTools: tools, autoAllowedToolsLoaded: true })
writeAutoAllowedToolsToStorage(tools)
logger.debug('[AutoAllowedTools] Added tool to store', { toolId })
}
} catch (err) {
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
}
},
removeAutoAllowedTool: async (toolId: string) => {
try {
const res = await fetch(
`${COPILOT_AUTO_ALLOWED_TOOLS_API_PATH}?toolId=${encodeURIComponent(toolId)}`,
{
method: 'DELETE',
}
)
if (res.ok) {
const data = await res.json()
const tools = data.autoAllowedTools ?? []
set({ autoAllowedTools: tools, autoAllowedToolsLoaded: true })
writeAutoAllowedToolsToStorage(tools)
logger.debug('[AutoAllowedTools] Removed tool', { toolId })
}
} catch (err) {
logger.error('[AutoAllowedTools] Failed to remove tool', { toolId, error: err })
}
},
isToolAutoAllowed: (toolId: string) => {
const { autoAllowedTools } = get()
return isToolAutoAllowedByList(toolId, autoAllowedTools)
},
// Credential masking
loadSensitiveCredentialIds: async () => {
try {

View File

@@ -26,26 +26,6 @@ export interface CopilotToolCall {
params?: Record<string, unknown>
input?: Record<string, unknown>
display?: ClientToolDisplay
/** Server-provided UI contract for this tool call phase */
ui?: {
title?: string
phaseLabel?: string
icon?: string
showInterrupt?: boolean
showRemember?: boolean
autoAllowed?: boolean
actions?: Array<{
id: string
label: string
kind: 'accept' | 'reject'
remember?: boolean
}>
}
/** Server-provided execution routing contract */
execution?: {
target?: 'go' | 'go_subagent' | 'sim_server' | 'sim_client_capability' | string
capabilityId?: string
}
/** Content streamed from a subagent (e.g., debug agent) */
subAgentContent?: string
/** Tool calls made by the subagent */
@@ -187,6 +167,10 @@ export interface CopilotState {
// Per-message metadata captured at send-time for reliable stats
// Auto-allowed integration tools (tools that can run without confirmation)
autoAllowedTools: string[]
autoAllowedToolsLoaded: boolean
// Active stream metadata for reconnect/replay
activeStream: CopilotStreamInfo | null
@@ -263,6 +247,11 @@ export interface CopilotActions {
abortSignal?: AbortSignal
) => Promise<void>
handleNewChatCreation: (newChatId: string) => Promise<void>
loadAutoAllowedTools: () => Promise<void>
addAutoAllowedTool: (toolId: string) => Promise<void>
removeAutoAllowedTool: (toolId: string) => Promise<void>
isToolAutoAllowed: (toolId: string) => boolean
// Credential masking
loadSensitiveCredentialIds: () => Promise<void>
maskCredentialValue: (value: string) => string

View File

@@ -15,7 +15,7 @@ import {
captureBaselineSnapshot,
cloneWorkflowState,
createBatchedUpdater,
findLatestWorkflowEditToolCallId,
findLatestEditWorkflowToolCallId,
getLatestUserMessageId,
persistWorkflowStateToServer,
} from './utils'
@@ -334,7 +334,7 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
})
}
findLatestWorkflowEditToolCallId().then((toolCallId) => {
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
import('@/stores/panel/copilot/store')
.then(({ useCopilotStore }) => {
@@ -439,7 +439,7 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
})
}
findLatestWorkflowEditToolCallId().then((toolCallId) => {
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
import('@/stores/panel/copilot/store')
.then(({ useCopilotStore }) => {

View File

@@ -126,21 +126,6 @@ export async function getLatestUserMessageId(): Promise<string | null> {
}
export async function findLatestEditWorkflowToolCallId(): Promise<string | undefined> {
return findLatestWorkflowEditToolCallId()
}
function isWorkflowEditToolCall(name?: string, params?: Record<string, unknown>): boolean {
if (name === 'edit_workflow') return true
if (name !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
// Be permissive for legacy/incomplete events: apply calls always include proposalId.
return typeof params?.proposalId === 'string' && params.proposalId.length > 0
}
export async function findLatestWorkflowEditToolCallId(): Promise<string | undefined> {
try {
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
const { messages, toolCallsById } = useCopilotStore.getState()
@@ -149,22 +134,17 @@ export async function findLatestWorkflowEditToolCallId(): Promise<string | undef
const message = messages[mi]
if (message.role !== 'assistant' || !message.contentBlocks) continue
for (const block of message.contentBlocks) {
if (
block?.type === 'tool_call' &&
isWorkflowEditToolCall(block.toolCall?.name, block.toolCall?.params)
) {
if (block?.type === 'tool_call' && block.toolCall?.name === 'edit_workflow') {
return block.toolCall?.id
}
}
}
const fallback = Object.values(toolCallsById).filter((call) =>
isWorkflowEditToolCall(call.name, call.params)
)
const fallback = Object.values(toolCallsById).filter((call) => call.name === 'edit_workflow')
return fallback.length ? fallback[fallback.length - 1].id : undefined
} catch (error) {
logger.warn('Failed to resolve workflow edit tool call id', { error })
logger.warn('Failed to resolve edit_workflow tool call id', { error })
return undefined
}
}

View File

@@ -1,8 +1,4 @@
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import { BLOGPOST_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreateBlogPostParams {
@@ -129,23 +125,6 @@ export const confluenceCreateBlogPostTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'Created blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID' },
authorId: { type: 'string', description: 'Author account ID', optional: true },
body: {
type: 'object',
description: 'Blog post body content',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
version: {
type: 'object',
description: 'Blog post version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
...BLOGPOST_ITEM_PROPERTIES,
},
}

View File

@@ -1,3 +1,4 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreateCommentParams {
@@ -99,7 +100,7 @@ export const confluenceCreateCommentTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of creation' },
ts: TIMESTAMP_OUTPUT,
commentId: { type: 'string', description: 'Created comment ID' },
pageId: { type: 'string', description: 'Page ID' },
},

View File

@@ -1,4 +1,8 @@
import { CONTENT_BODY_OUTPUT_PROPERTIES, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreatePageParams {
@@ -128,7 +132,7 @@ export const confluenceCreatePageTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of creation' },
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'Created page ID' },
title: { type: 'string', description: 'Page title' },
status: { type: 'string', description: 'Page status', optional: true },

View File

@@ -1,4 +1,4 @@
import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import { PAGE_PROPERTY_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreatePagePropertyParams {
@@ -115,13 +115,6 @@ export const confluenceCreatePagePropertyTool: ToolConfig<
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'ID of the page' },
propertyId: { type: 'string', description: 'ID of the created property' },
key: { type: 'string', description: 'Property key' },
value: { type: 'json', description: 'Property value' },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
...PAGE_PROPERTY_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,123 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreateSpaceParams {
accessToken: string
domain: string
name: string
key: string
description?: string
cloudId?: string
}
export interface ConfluenceCreateSpaceResponse {
success: boolean
output: {
ts: string
id: string
key: string
name: string
type: string
status: string
homepageId: string | null
}
}
export const confluenceCreateSpaceTool: ToolConfig<
ConfluenceCreateSpaceParams,
ConfluenceCreateSpaceResponse
> = {
id: 'confluence_create_space',
name: 'Confluence Create Space',
description: 'Create a new Confluence space.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name for the new space',
},
key: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Unique key for the space (short identifier used in URLs)',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Description for the space',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/spaces',
method: 'POST',
headers: (params: ConfluenceCreateSpaceParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceCreateSpaceParams) => ({
domain: params.domain,
accessToken: params.accessToken,
name: params.name,
key: params.key,
description: params.description,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
key: data.key ?? '',
name: data.name ?? '',
type: data.type ?? '',
status: data.status ?? '',
homepageId: data.homepageId ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'ID of the created space' },
key: { type: 'string', description: 'Key of the created space' },
name: { type: 'string', description: 'Name of the created space' },
type: { type: 'string', description: 'Type of the space' },
status: { type: 'string', description: 'Status of the space' },
homepageId: { type: 'string', description: 'ID of the space homepage', optional: true },
},
}

View File

@@ -0,0 +1,118 @@
import { SPACE_PROPERTY_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreateSpacePropertyParams {
accessToken: string
domain: string
spaceId: string
key: string
value: unknown
cloudId?: string
}
export interface ConfluenceCreateSpacePropertyResponse {
success: boolean
output: {
ts: string
spaceId: string
propertyId: string
key: string
value: unknown
version: { number: number } | null
}
}
export const confluenceCreateSpacePropertyTool: ToolConfig<
ConfluenceCreateSpacePropertyParams,
ConfluenceCreateSpacePropertyResponse
> = {
id: 'confluence_create_space_property',
name: 'Confluence Create Space Property',
description: 'Create a new content property on a Confluence space.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space to add the property to',
},
key: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The key/name for the property',
},
value: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description: 'The value for the property (can be any JSON value)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/space-properties',
method: 'POST',
headers: (params: ConfluenceCreateSpacePropertyParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceCreateSpacePropertyParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
key: params.key,
value: params.value,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.spaceId ?? '',
propertyId: data.id ?? '',
key: data.key ?? '',
value: data.value ?? null,
version: data.version ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'ID of the space' },
propertyId: { type: 'string', description: 'ID of the created property' },
...SPACE_PROPERTY_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,127 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceCreateWhiteboardParams {
accessToken: string
domain: string
spaceId: string
title: string
parentId?: string
cloudId?: string
}
export interface ConfluenceCreateWhiteboardResponse {
success: boolean
output: {
ts: string
id: string
title: string
spaceId: string
parentId: string | null
parentType: string | null
authorId: string | null
createdAt: string | null
}
}
export const confluenceCreateWhiteboardTool: ToolConfig<
ConfluenceCreateWhiteboardParams,
ConfluenceCreateWhiteboardResponse
> = {
id: 'confluence_create_whiteboard',
name: 'Confluence Create Whiteboard',
description: 'Create a new whiteboard in a Confluence space.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space to create the whiteboard in',
},
title: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Title for the whiteboard',
},
parentId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'ID of the parent content (optional)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/whiteboards',
method: 'POST',
headers: (params: ConfluenceCreateWhiteboardParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceCreateWhiteboardParams) => ({
action: 'create',
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
title: params.title,
parentId: params.parentId?.trim(),
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
title: data.title ?? '',
spaceId: data.spaceId ?? '',
parentId: data.parentId ?? null,
parentType: data.parentType ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'ID of the created whiteboard' },
title: { type: 'string', description: 'Title of the whiteboard' },
spaceId: { type: 'string', description: 'ID of the space' },
parentId: { type: 'string', description: 'ID of the parent content', optional: true },
parentType: { type: 'string', description: 'Type of the parent content', optional: true },
authorId: { type: 'string', description: 'Author account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
},
}

View File

@@ -1,3 +1,4 @@
import { DELETED_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceDeleteAttachmentParams {
@@ -90,8 +91,8 @@ export const confluenceDeleteAttachmentTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of deletion' },
ts: TIMESTAMP_OUTPUT,
attachmentId: { type: 'string', description: 'Deleted attachment ID' },
deleted: { type: 'boolean', description: 'Deletion status' },
deleted: DELETED_OUTPUT,
},
}

View File

@@ -0,0 +1,82 @@
import type {
ConfluenceDeleteBlogPostParams,
ConfluenceDeleteBlogPostResponse,
} from '@/tools/confluence/types'
import { DELETED_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export const confluenceDeleteBlogPostTool: ToolConfig<
ConfluenceDeleteBlogPostParams,
ConfluenceDeleteBlogPostResponse
> = {
id: 'confluence_delete_blogpost',
name: 'Confluence Delete Blog Post',
description: 'Delete a Confluence blog post.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
blogPostId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the blog post to delete',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/blogposts',
method: 'DELETE',
headers: (params: ConfluenceDeleteBlogPostParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceDeleteBlogPostParams) => ({
domain: params.domain,
accessToken: params.accessToken,
blogPostId: params.blogPostId?.trim(),
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
blogPostId: data.blogPostId ?? '',
deleted: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
blogPostId: { type: 'string', description: 'Deleted blog post ID' },
deleted: DELETED_OUTPUT,
},
}

View File

@@ -1,3 +1,4 @@
import { DELETED_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceDeleteCommentParams {
@@ -90,8 +91,8 @@ export const confluenceDeleteCommentTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of deletion' },
ts: TIMESTAMP_OUTPUT,
commentId: { type: 'string', description: 'Deleted comment ID' },
deleted: { type: 'boolean', description: 'Deletion status' },
deleted: DELETED_OUTPUT,
},
}

View File

@@ -1,3 +1,4 @@
import { DELETED_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceDeletePageParams {
@@ -100,8 +101,8 @@ export const confluenceDeletePageTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of deletion' },
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'Deleted page ID' },
deleted: { type: 'boolean', description: 'Deletion status' },
deleted: DELETED_OUTPUT,
},
}

View File

@@ -0,0 +1,94 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceDeleteSpaceParams {
accessToken: string
domain: string
spaceId: string
cloudId?: string
}
export interface ConfluenceDeleteSpaceResponse {
success: boolean
output: {
ts: string
spaceId: string
deleted: boolean
}
}
export const confluenceDeleteSpaceTool: ToolConfig<
ConfluenceDeleteSpaceParams,
ConfluenceDeleteSpaceResponse
> = {
id: 'confluence_delete_space',
name: 'Confluence Delete Space',
description: 'Delete a Confluence space by its ID.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space to delete',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/spaces',
method: 'DELETE',
headers: (params: ConfluenceDeleteSpaceParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceDeleteSpaceParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.spaceId ?? '',
deleted: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'ID of the deleted space' },
deleted: { type: 'boolean', description: 'Deletion status' },
},
}

View File

@@ -1,8 +1,4 @@
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import { BLOGPOST_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetBlogPostParams {
@@ -121,24 +117,6 @@ export const confluenceGetBlogPostTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'Blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
authorId: { type: 'string', description: 'Author account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
body: {
type: 'object',
description: 'Blog post body content in requested format(s)',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
...BLOGPOST_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,111 @@
import { SPACE_PROPERTY_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetSpacePropertyParams {
accessToken: string
domain: string
spaceId: string
propertyId: string
cloudId?: string
}
export interface ConfluenceGetSpacePropertyResponse {
success: boolean
output: {
ts: string
spaceId: string
id: string
key: string
value: unknown
version: { number: number } | null
}
}
export const confluenceGetSpacePropertyTool: ToolConfig<
ConfluenceGetSpacePropertyParams,
ConfluenceGetSpacePropertyResponse
> = {
id: 'confluence_get_space_property',
name: 'Confluence Get Space Property',
description: 'Get a specific content property from a Confluence space by its ID.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space containing the property',
},
propertyId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the property to retrieve',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: ConfluenceGetSpacePropertyParams) => {
const query = new URLSearchParams({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId,
propertyId: params.propertyId,
action: 'get',
})
if (params.cloudId) query.set('cloudId', params.cloudId)
return `/api/tools/confluence/space-properties?${query.toString()}`
},
method: 'GET',
headers: (params: ConfluenceGetSpacePropertyParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.spaceId ?? '',
id: data.id ?? '',
key: data.key ?? '',
value: data.value ?? null,
version: data.version ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'ID of the space' },
...SPACE_PROPERTY_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,117 @@
import { TASK_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetTaskParams {
accessToken: string
domain: string
taskId: string
cloudId?: string
}
export interface ConfluenceGetTaskResponse {
success: boolean
output: {
ts: string
id: string
localId: string
spaceId: string | null
pageId: string | null
blogPostId: string | null
status: string
body: Record<string, unknown> | null
createdBy: string | null
assignedTo: string | null
completedBy: string | null
createdAt: string | null
updatedAt: string | null
dueAt: string | null
completedAt: string | null
}
}
export const confluenceGetTaskTool: ToolConfig<ConfluenceGetTaskParams, ConfluenceGetTaskResponse> =
{
id: 'confluence_get_task',
name: 'Confluence Get Task',
description: 'Get a specific task by its ID from Confluence.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
taskId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the task to retrieve',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/tasks',
method: 'POST',
headers: (params: ConfluenceGetTaskParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceGetTaskParams) => ({
action: 'get',
domain: params.domain,
accessToken: params.accessToken,
taskId: params.taskId?.trim(),
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
localId: data.localId ?? '',
spaceId: data.spaceId ?? null,
pageId: data.pageId ?? null,
blogPostId: data.blogPostId ?? null,
status: data.status ?? '',
body: data.body ?? null,
createdBy: data.createdBy ?? null,
assignedTo: data.assignedTo ?? null,
completedBy: data.completedBy ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
dueAt: data.dueAt ?? null,
completedAt: data.completedAt ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
...TASK_ITEM_PROPERTIES,
},
}

View File

@@ -4,6 +4,7 @@ import { confluenceCreateCommentTool } from '@/tools/confluence/create_comment'
import { confluenceCreatePageTool } from '@/tools/confluence/create_page'
import { confluenceCreatePagePropertyTool } from '@/tools/confluence/create_page_property'
import { confluenceDeleteAttachmentTool } from '@/tools/confluence/delete_attachment'
import { confluenceDeleteBlogPostTool } from '@/tools/confluence/delete_blogpost'
import { confluenceDeleteCommentTool } from '@/tools/confluence/delete_comment'
import { confluenceDeleteLabelTool } from '@/tools/confluence/delete_label'
import { confluenceDeletePageTool } from '@/tools/confluence/delete_page'
@@ -31,6 +32,9 @@ import {
ATTACHMENT_ITEM_PROPERTIES,
ATTACHMENT_OUTPUT,
ATTACHMENTS_OUTPUT,
BLOGPOST_ITEM_PROPERTIES,
BLOGPOST_OUTPUT,
BLOGPOSTS_OUTPUT,
BODY_FORMAT_PROPERTIES,
COMMENT_BODY_OUTPUT_PROPERTIES,
COMMENT_ITEM_PROPERTIES,
@@ -47,6 +51,7 @@ import {
PAGE_ID_OUTPUT,
PAGE_ITEM_PROPERTIES,
PAGE_OUTPUT,
PAGE_PROPERTY_ITEM_PROPERTIES,
PAGES_OUTPUT,
PAGINATION_LINKS_PROPERTIES,
SEARCH_RESULT_ITEM_PROPERTIES,
@@ -59,12 +64,15 @@ import {
SPACES_OUTPUT,
SUCCESS_OUTPUT,
TIMESTAMP_OUTPUT,
UPDATED_OUTPUT,
URL_OUTPUT,
VERSION_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import { confluenceUpdateTool } from '@/tools/confluence/update'
import { confluenceUpdateBlogPostTool } from '@/tools/confluence/update_blogpost'
import { confluenceUpdateCommentTool } from '@/tools/confluence/update_comment'
import { confluenceUpdatePagePropertyTool } from '@/tools/confluence/update_page_property'
import { confluenceUploadAttachmentTool } from '@/tools/confluence/upload_attachment'
export {
@@ -82,11 +90,14 @@ export {
// Page Properties Tools
confluenceListPagePropertiesTool,
confluenceCreatePagePropertyTool,
confluenceUpdatePagePropertyTool,
confluenceDeletePagePropertyTool,
// Blog Post Tools
confluenceListBlogPostsTool,
confluenceGetBlogPostTool,
confluenceCreateBlogPostTool,
confluenceUpdateBlogPostTool,
confluenceDeleteBlogPostTool,
confluenceListBlogPostsInSpaceTool,
// Search Tools
confluenceSearchTool,
@@ -111,9 +122,11 @@ export {
confluenceListSpacesTool,
// Item property constants (for use in outputs)
ATTACHMENT_ITEM_PROPERTIES,
BLOGPOST_ITEM_PROPERTIES,
COMMENT_ITEM_PROPERTIES,
LABEL_ITEM_PROPERTIES,
PAGE_ITEM_PROPERTIES,
PAGE_PROPERTY_ITEM_PROPERTIES,
SEARCH_RESULT_ITEM_PROPERTIES,
SPACE_ITEM_PROPERTIES,
VERSION_OUTPUT_PROPERTIES,
@@ -127,6 +140,8 @@ export {
// Complete output definitions (for use in outputs)
ATTACHMENT_OUTPUT,
ATTACHMENTS_OUTPUT,
BLOGPOST_OUTPUT,
BLOGPOSTS_OUTPUT,
COMMENT_OUTPUT,
COMMENTS_OUTPUT,
CONTENT_BODY_OUTPUT,
@@ -145,5 +160,6 @@ export {
PAGE_ID_OUTPUT,
SUCCESS_OUTPUT,
DELETED_OUTPUT,
UPDATED_OUTPUT,
URL_OUTPUT,
}

View File

@@ -1,4 +1,4 @@
import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import { BLOGPOST_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListBlogPostsParams {
@@ -141,21 +141,7 @@ export const confluenceListBlogPostsTool: ToolConfig<
description: 'Array of blog posts',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
authorId: { type: 'string', description: 'Author account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
},
properties: BLOGPOST_ITEM_PROPERTIES,
},
},
nextCursor: {

View File

@@ -1,8 +1,4 @@
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import { BLOGPOST_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListBlogPostsInSpaceParams {
@@ -146,27 +142,7 @@ export const confluenceListBlogPostsInSpaceTool: ToolConfig<
description: 'Array of blog posts in the space',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Blog post ID' },
title: { type: 'string', description: 'Blog post title' },
status: { type: 'string', description: 'Blog post status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
authorId: { type: 'string', description: 'Author account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
body: {
type: 'object',
description: 'Blog post body content',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: { type: 'string', description: 'URL to view the blog post', optional: true },
},
properties: BLOGPOST_ITEM_PROPERTIES,
},
},
nextCursor: {

View File

@@ -1,4 +1,4 @@
import { LABEL_ITEM_PROPERTIES } from '@/tools/confluence/types'
import { LABEL_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListLabelsParams {
@@ -115,7 +115,7 @@ export const confluenceListLabelsTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of retrieval' },
ts: TIMESTAMP_OUTPUT,
labels: {
type: 'array',
description: 'Array of labels on the page',

View File

@@ -1,4 +1,4 @@
import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import { PAGE_PROPERTY_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListPagePropertiesParams {
@@ -127,17 +127,7 @@ export const confluenceListPagePropertiesTool: ToolConfig<
description: 'Array of content properties',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Property ID' },
key: { type: 'string', description: 'Property key' },
value: { type: 'json', description: 'Property value (can be any JSON)' },
version: {
type: 'object',
description: 'Version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
},
properties: PAGE_PROPERTY_ITEM_PROPERTIES,
},
},
nextCursor: {

View File

@@ -0,0 +1,126 @@
import { SPACE_PROPERTY_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListSpacePropertiesParams {
accessToken: string
domain: string
spaceId: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceListSpacePropertiesResponse {
success: boolean
output: {
ts: string
spaceId: string
properties: Array<Record<string, unknown>>
nextCursor: string | null
}
}
export const confluenceListSpacePropertiesTool: ToolConfig<
ConfluenceListSpacePropertiesParams,
ConfluenceListSpacePropertiesResponse
> = {
id: 'confluence_list_space_properties',
name: 'Confluence List Space Properties',
description: 'List all content properties on a Confluence space.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space to list properties from',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of properties to return (default: 50, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: ConfluenceListSpacePropertiesParams) => {
const query = new URLSearchParams({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId,
limit: String(params.limit || 50),
})
if (params.cursor) query.set('cursor', params.cursor)
if (params.cloudId) query.set('cloudId', params.cloudId)
return `/api/tools/confluence/space-properties?${query.toString()}`
},
method: 'GET',
headers: (params: ConfluenceListSpacePropertiesParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.spaceId ?? '',
properties: data.properties ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'ID of the space' },
properties: {
type: 'array',
description: 'Array of space properties',
items: {
type: 'object',
properties: SPACE_PROPERTY_ITEM_PROPERTIES,
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,139 @@
import { TASK_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListTasksParams {
accessToken: string
domain: string
spaceId?: string
pageId?: string
status?: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceListTasksResponse {
success: boolean
output: {
ts: string
tasks: Array<Record<string, unknown>>
nextCursor: string | null
}
}
export const confluenceListTasksTool: ToolConfig<
ConfluenceListTasksParams,
ConfluenceListTasksResponse
> = {
id: 'confluence_list_tasks',
name: 'Confluence List Tasks',
description: 'List tasks from Confluence, optionally filtered by space, page, or status.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter tasks by space ID',
},
pageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter tasks by page ID',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter tasks by status (complete or incomplete)',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of tasks to return (default: 50, max: 250)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from previous response',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: ConfluenceListTasksParams) => {
const query = new URLSearchParams({
domain: params.domain,
accessToken: params.accessToken,
limit: String(params.limit || 50),
})
if (params.spaceId) query.set('spaceId', params.spaceId)
if (params.pageId) query.set('pageId', params.pageId)
if (params.status) query.set('status', params.status)
if (params.cursor) query.set('cursor', params.cursor)
if (params.cloudId) query.set('cloudId', params.cloudId)
return `/api/tools/confluence/tasks?${query.toString()}`
},
method: 'GET',
headers: (params: ConfluenceListTasksParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
tasks: data.tasks ?? [],
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
tasks: {
type: 'array',
description: 'Array of Confluence tasks',
items: {
type: 'object',
properties: TASK_ITEM_PROPERTIES,
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -1,4 +1,4 @@
import { SEARCH_RESULT_ITEM_PROPERTIES } from '@/tools/confluence/types'
import { SEARCH_RESULT_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceSearchParams {
@@ -101,7 +101,7 @@ export const confluenceSearchTool: ToolConfig<ConfluenceSearchParams, Confluence
},
outputs: {
ts: { type: 'string', description: 'Timestamp of search' },
ts: TIMESTAMP_OUTPUT,
results: {
type: 'array',
description: 'Array of search results',

View File

@@ -382,6 +382,393 @@ export const LABELS_OUTPUT: OutputProperty = {
},
}
/**
* Blog post item properties from Confluence API v2.
* Based on GET /wiki/api/v2/blogposts response structure.
*/
export const BLOGPOST_ITEM_PROPERTIES = {
id: { type: 'string', description: 'Unique blog post identifier' },
title: { type: 'string', description: 'Blog post title' },
status: {
type: 'string',
description: 'Blog post status (e.g., current, draft)',
optional: true,
},
spaceId: {
type: 'string',
description: 'ID of the space containing the blog post',
optional: true,
},
authorId: { type: 'string', description: 'Account ID of the blog post author', optional: true },
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp when the blog post was created',
optional: true,
},
version: {
type: 'object',
description: 'Blog post version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
body: {
type: 'object',
description: 'Blog post body content',
properties: CONTENT_BODY_OUTPUT_PROPERTIES,
optional: true,
},
webUrl: {
type: 'string',
description: 'URL to view the blog post in Confluence',
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Complete blog post object output definition.
*/
export const BLOGPOST_OUTPUT: OutputProperty = {
type: 'object',
description: 'Confluence blog post object',
properties: BLOGPOST_ITEM_PROPERTIES,
}
/**
* Blog posts array output definition for list endpoints.
*/
export const BLOGPOSTS_OUTPUT: OutputProperty = {
type: 'array',
description: 'Array of Confluence blog posts',
items: {
type: 'object',
properties: BLOGPOST_ITEM_PROPERTIES,
},
}
/**
* Page property item properties from Confluence API v2.
* Based on GET /wiki/api/v2/pages/{id}/properties response structure.
*/
export const PAGE_PROPERTY_ITEM_PROPERTIES = {
id: { type: 'string', description: 'Unique property identifier' },
key: { type: 'string', description: 'Property key/name' },
value: { type: 'json', description: 'Property value (can be any JSON)' },
version: {
type: 'object',
description: 'Property version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Space property item properties from Confluence API v2.
* Same shape as page properties (id, key, value, version).
*/
export const SPACE_PROPERTY_ITEM_PROPERTIES = {
...PAGE_PROPERTY_ITEM_PROPERTIES,
} as const satisfies Record<string, OutputProperty>
/**
* Task item properties from Confluence API v2.
* Based on GET /wiki/api/v2/tasks response structure.
*/
export const TASK_ITEM_PROPERTIES = {
id: { type: 'string', description: 'Unique task identifier' },
localId: { type: 'string', description: 'Local task identifier within the content' },
spaceId: { type: 'string', description: 'ID of the space containing the task', optional: true },
pageId: {
type: 'string',
description: 'ID of the page containing the task',
optional: true,
},
blogPostId: {
type: 'string',
description: 'ID of the blog post containing the task',
optional: true,
},
status: { type: 'string', description: 'Task status (e.g., complete, incomplete)' },
body: {
type: 'object',
description: 'Task body content',
properties: {
value: { type: 'string', description: 'Task body text' },
representation: { type: 'string', description: 'Content representation format' },
},
optional: true,
},
createdBy: { type: 'string', description: 'Account ID of the task creator', optional: true },
assignedTo: {
type: 'string',
description: 'Account ID of the assigned user',
optional: true,
},
completedBy: {
type: 'string',
description: 'Account ID of the user who completed the task',
optional: true,
},
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp when the task was created',
optional: true,
},
updatedAt: {
type: 'string',
description: 'ISO 8601 timestamp when the task was last updated',
optional: true,
},
dueAt: {
type: 'string',
description: 'ISO 8601 timestamp for the task due date',
optional: true,
},
completedAt: {
type: 'string',
description: 'ISO 8601 timestamp when the task was completed',
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Complete task object output definition.
*/
export const TASK_OUTPUT: OutputProperty = {
type: 'object',
description: 'Confluence task object',
properties: TASK_ITEM_PROPERTIES,
}
/**
* Tasks array output definition for list endpoints.
*/
export const TASKS_OUTPUT: OutputProperty = {
type: 'array',
description: 'Array of Confluence tasks',
items: {
type: 'object',
properties: TASK_ITEM_PROPERTIES,
},
}
/**
* Whiteboard item properties from Confluence API v2.
* Based on POST /wiki/api/v2/whiteboards response structure.
*/
export const WHITEBOARD_ITEM_PROPERTIES = {
id: { type: 'string', description: 'Unique whiteboard identifier' },
title: { type: 'string', description: 'Whiteboard title' },
spaceId: { type: 'string', description: 'ID of the space containing the whiteboard' },
parentId: {
type: 'string',
description: 'ID of the parent content',
optional: true,
},
parentType: {
type: 'string',
description: 'Type of the parent content (e.g., page, space, whiteboard)',
optional: true,
},
position: {
type: 'number',
description: 'Position of the whiteboard among siblings',
optional: true,
},
authorId: {
type: 'string',
description: 'Account ID of the whiteboard creator',
optional: true,
},
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp when the whiteboard was created',
optional: true,
},
version: {
type: 'object',
description: 'Whiteboard version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Database item properties from Confluence API v2.
* Based on POST /wiki/api/v2/databases response structure.
*/
export const DATABASE_ITEM_PROPERTIES = {
id: { type: 'string', description: 'Unique database identifier' },
title: { type: 'string', description: 'Database title' },
spaceId: { type: 'string', description: 'ID of the space containing the database' },
parentId: {
type: 'string',
description: 'ID of the parent content',
optional: true,
},
parentType: {
type: 'string',
description: 'Type of the parent content',
optional: true,
},
position: {
type: 'number',
description: 'Position of the database among siblings',
optional: true,
},
status: {
type: 'string',
description: 'Database status (e.g., current, trashed)',
optional: true,
},
authorId: {
type: 'string',
description: 'Account ID of the database creator',
optional: true,
},
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp when the database was created',
optional: true,
},
version: {
type: 'object',
description: 'Database version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Folder item properties from Confluence API v2.
* Based on POST /wiki/api/v2/folders response structure.
*/
export const FOLDER_ITEM_PROPERTIES = {
id: { type: 'string', description: 'Unique folder identifier' },
title: { type: 'string', description: 'Folder title' },
spaceId: { type: 'string', description: 'ID of the space containing the folder' },
parentId: {
type: 'string',
description: 'ID of the parent content',
optional: true,
},
parentType: {
type: 'string',
description: 'Type of the parent content',
optional: true,
},
position: {
type: 'number',
description: 'Position of the folder among siblings',
optional: true,
},
status: {
type: 'string',
description: 'Folder status (e.g., current, trashed)',
optional: true,
},
authorId: {
type: 'string',
description: 'Account ID of the folder creator',
optional: true,
},
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp when the folder was created',
optional: true,
},
version: {
type: 'object',
description: 'Folder version information',
properties: VERSION_OUTPUT_PROPERTIES,
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Inline comment item properties from Confluence API v2.
* Extends COMMENT_ITEM_PROPERTIES with resolution fields.
*/
export const INLINE_COMMENT_ITEM_PROPERTIES = {
...COMMENT_ITEM_PROPERTIES,
resolutionStatus: {
type: 'string',
description: 'Resolution status of the inline comment (e.g., open, resolved)',
optional: true,
},
resolutionLastModifiedBy: {
type: 'string',
description: 'Account ID of the user who last modified the resolution status',
optional: true,
},
resolutionLastModifiedAt: {
type: 'string',
description: 'ISO 8601 timestamp when the resolution was last modified',
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Space permission item properties from Confluence API v2.
* Based on GET /wiki/api/v2/spaces/{id}/permissions response structure.
*/
export const SPACE_PERMISSION_ITEM_PROPERTIES = {
id: { type: 'string', description: 'Unique permission identifier' },
principalType: {
type: 'string',
description: 'Type of the principal (e.g., user, group, role)',
},
principalId: { type: 'string', description: 'ID of the principal' },
operationKey: {
type: 'string',
description: 'Permission operation key (e.g., read, delete, administer)',
},
operationTargetType: {
type: 'string',
description: 'Target type for the operation (e.g., space, page, blogpost)',
},
isAnonymous: {
type: 'boolean',
description: 'Whether this permission applies to anonymous users',
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* User item properties from Confluence API v2.
* Based on GET /wiki/api/v2/users-bulk response structure.
*/
export const USER_ITEM_PROPERTIES = {
accountId: { type: 'string', description: 'Unique Atlassian account identifier' },
accountType: {
type: 'string',
description: 'Account type (e.g., atlassian, app, customer)',
},
accountStatus: {
type: 'string',
description: 'Account status (e.g., active, inactive, closed)',
optional: true,
},
displayName: { type: 'string', description: 'User display name' },
publicName: { type: 'string', description: 'User public name', optional: true },
email: { type: 'string', description: 'User email address', optional: true },
timeZone: { type: 'string', description: 'User time zone', optional: true },
personalSpaceId: {
type: 'string',
description: 'ID of the user personal space',
optional: true,
},
isExternalCollaborator: {
type: 'boolean',
description: 'Whether the user is an external collaborator',
optional: true,
},
profilePicture: {
type: 'string',
description: 'URL to the user profile picture',
optional: true,
},
} as const satisfies Record<string, OutputProperty>
/**
* Search result space info properties.
* Based on Confluence search API space object in results.
@@ -495,6 +882,14 @@ export const URL_OUTPUT: OutputProperty = {
description: 'URL to view in Confluence',
}
/**
* Common updated status output property.
*/
export const UPDATED_OUTPUT: OutputProperty = {
type: 'boolean',
description: 'Update status',
}
// Page operations
export interface ConfluenceRetrieveParams {
accessToken: string
@@ -710,6 +1105,149 @@ export interface ConfluenceSpaceResponse extends ToolResponse {
}
}
// Blog post update/delete operations
export interface ConfluenceUpdateBlogPostParams {
accessToken: string
domain: string
blogPostId: string
title?: string
content?: string
status?: string
cloudId?: string
}
export interface ConfluenceUpdateBlogPostResponse extends ToolResponse {
output: {
ts: string
id: string
title: string
status: string | null
spaceId: string | null
authorId: string | null
body: Record<string, unknown> | null
version: Record<string, unknown> | null
webUrl: string | null
}
}
export interface ConfluenceDeleteBlogPostParams {
accessToken: string
domain: string
blogPostId: string
cloudId?: string
}
export interface ConfluenceDeleteBlogPostResponse extends ToolResponse {
output: {
ts: string
blogPostId: string
deleted: boolean
}
}
// Page property update
export interface ConfluenceUpdatePagePropertyParams {
accessToken: string
domain: string
pageId: string
propertyId: string
key: string
value: unknown
versionNumber: number
cloudId?: string
}
export interface ConfluenceUpdatePagePropertyResponse extends ToolResponse {
output: {
ts: string
pageId: string
propertyId: string
key: string
value: unknown
version: Record<string, unknown> | null
}
}
// Task operations
export interface ConfluenceTaskResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Whiteboard operations
export interface ConfluenceWhiteboardResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Database operations
export interface ConfluenceDatabaseResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Folder operations
export interface ConfluenceFolderResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Inline comment operations
export interface ConfluenceInlineCommentResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Space permission operations
export interface ConfluenceSpacePermissionResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Space property operations
export interface ConfluenceSpacePropertyResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// User bulk operations
export interface ConfluenceUserBulkResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Blog post label operations
export interface ConfluenceBlogPostLabelResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
// Blog post version operations
export interface ConfluenceBlogPostVersionResponse extends ToolResponse {
output: {
ts: string
[key: string]: unknown
}
}
export type ConfluenceResponse =
| ConfluenceRetrieveResponse
| ConfluenceUpdateResponse
@@ -721,3 +1259,16 @@ export type ConfluenceResponse =
| ConfluenceUploadAttachmentResponse
| ConfluenceLabelResponse
| ConfluenceSpaceResponse
| ConfluenceUpdateBlogPostResponse
| ConfluenceDeleteBlogPostResponse
| ConfluenceUpdatePagePropertyResponse
| ConfluenceTaskResponse
| ConfluenceWhiteboardResponse
| ConfluenceDatabaseResponse
| ConfluenceFolderResponse
| ConfluenceInlineCommentResponse
| ConfluenceSpacePermissionResponse
| ConfluenceSpacePropertyResponse
| ConfluenceUserBulkResponse
| ConfluenceBlogPostLabelResponse
| ConfluenceBlogPostVersionResponse

View File

@@ -1,5 +1,10 @@
import type { ConfluenceUpdateParams, ConfluenceUpdateResponse } from '@/tools/confluence/types'
import { CONTENT_BODY_OUTPUT_PROPERTIES, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types'
import {
CONTENT_BODY_OUTPUT_PROPERTIES,
SUCCESS_OUTPUT,
TIMESTAMP_OUTPUT,
VERSION_OUTPUT_PROPERTIES,
} from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export const confluenceUpdateTool: ToolConfig<ConfluenceUpdateParams, ConfluenceUpdateResponse> = {
@@ -112,7 +117,7 @@ export const confluenceUpdateTool: ToolConfig<ConfluenceUpdateParams, Confluence
},
outputs: {
ts: { type: 'string', description: 'Timestamp of update' },
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'Confluence page ID' },
title: { type: 'string', description: 'Updated page title' },
status: { type: 'string', description: 'Page status', optional: true },
@@ -130,6 +135,6 @@ export const confluenceUpdateTool: ToolConfig<ConfluenceUpdateParams, Confluence
optional: true,
},
url: { type: 'string', description: 'URL to view the page in Confluence', optional: true },
success: { type: 'boolean', description: 'Update operation success status' },
success: SUCCESS_OUTPUT,
},
}

View File

@@ -0,0 +1,109 @@
import type {
ConfluenceUpdateBlogPostParams,
ConfluenceUpdateBlogPostResponse,
} from '@/tools/confluence/types'
import { BLOGPOST_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export const confluenceUpdateBlogPostTool: ToolConfig<
ConfluenceUpdateBlogPostParams,
ConfluenceUpdateBlogPostResponse
> = {
id: 'confluence_update_blogpost',
name: 'Confluence Update Blog Post',
description: 'Update an existing Confluence blog post title, content, or status.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
blogPostId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the blog post to update',
},
title: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New title for the blog post',
},
content: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New content for the blog post in Confluence storage format',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Blog post status: current or draft',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/blogposts',
method: 'PUT',
headers: (params: ConfluenceUpdateBlogPostParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceUpdateBlogPostParams) => ({
domain: params.domain,
accessToken: params.accessToken,
blogPostId: params.blogPostId?.trim(),
title: params.title,
content: params.content,
status: params.status,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
title: data.title ?? '',
status: data.status ?? null,
spaceId: data.spaceId ?? null,
authorId: data.authorId ?? null,
body: data.body ?? null,
version: data.version ?? null,
webUrl: data.webUrl ?? data._links?.webui ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
...BLOGPOST_ITEM_PROPERTIES,
},
}

View File

@@ -1,3 +1,4 @@
import { TIMESTAMP_OUTPUT, UPDATED_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceUpdateCommentParams {
@@ -99,8 +100,8 @@ export const confluenceUpdateCommentTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of update' },
ts: TIMESTAMP_OUTPUT,
commentId: { type: 'string', description: 'Updated comment ID' },
updated: { type: 'boolean', description: 'Update status' },
updated: UPDATED_OUTPUT,
},
}

View File

@@ -0,0 +1,115 @@
import type {
ConfluenceUpdatePagePropertyParams,
ConfluenceUpdatePagePropertyResponse,
} from '@/tools/confluence/types'
import { PAGE_PROPERTY_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export const confluenceUpdatePagePropertyTool: ToolConfig<
ConfluenceUpdatePagePropertyParams,
ConfluenceUpdatePagePropertyResponse
> = {
id: 'confluence_update_page_property',
name: 'Confluence Update Page Property',
description: 'Update an existing content property on a Confluence page.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the page containing the property',
},
propertyId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the property to update',
},
key: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The key/name of the property',
},
value: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description: 'The new value for the property (can be any JSON value)',
},
versionNumber: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The current version number of the property (for conflict prevention)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/page-properties',
method: 'PUT',
headers: (params: ConfluenceUpdatePagePropertyParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceUpdatePagePropertyParams) => ({
domain: params.domain,
accessToken: params.accessToken,
pageId: params.pageId?.trim(),
propertyId: params.propertyId?.trim(),
key: params.key,
value: params.value,
versionNumber: Number(params.versionNumber),
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
propertyId: data.id ?? '',
key: data.key ?? '',
value: data.value ?? null,
version: data.version ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'ID of the page' },
propertyId: { type: 'string', description: 'ID of the updated property' },
...PAGE_PROPERTY_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,131 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceUpdateSpaceParams {
accessToken: string
domain: string
spaceId: string
name?: string
description?: string
status?: string
cloudId?: string
}
export interface ConfluenceUpdateSpaceResponse {
success: boolean
output: {
ts: string
id: string
key: string
name: string
type: string
status: string
updated: boolean
}
}
export const confluenceUpdateSpaceTool: ToolConfig<
ConfluenceUpdateSpaceParams,
ConfluenceUpdateSpaceResponse
> = {
id: 'confluence_update_space',
name: 'Confluence Update Space',
description: 'Update an existing Confluence space (name, description, or status).',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space to update',
},
name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New name for the space',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New description for the space',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New status for the space (current or archived)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/spaces',
method: 'PUT',
headers: (params: ConfluenceUpdateSpaceParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceUpdateSpaceParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
name: params.name,
description: params.description,
status: params.status,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
key: data.key ?? '',
name: data.name ?? '',
type: data.type ?? '',
status: data.status ?? '',
updated: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'ID of the updated space' },
key: { type: 'string', description: 'Key of the updated space' },
name: { type: 'string', description: 'Name of the updated space' },
type: { type: 'string', description: 'Type of the space' },
status: { type: 'string', description: 'Status of the space' },
updated: { type: 'boolean', description: 'Update status' },
},
}

View File

@@ -0,0 +1,134 @@
import { SPACE_PROPERTY_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceUpdateSpacePropertyParams {
accessToken: string
domain: string
spaceId: string
propertyId: string
key: string
value: unknown
versionNumber: number
cloudId?: string
}
export interface ConfluenceUpdateSpacePropertyResponse {
success: boolean
output: {
ts: string
spaceId: string
propertyId: string
key: string
value: unknown
version: Record<string, unknown> | null
}
}
export const confluenceUpdateSpacePropertyTool: ToolConfig<
ConfluenceUpdateSpacePropertyParams,
ConfluenceUpdateSpacePropertyResponse
> = {
id: 'confluence_update_space_property',
name: 'Confluence Update Space Property',
description: 'Update a content property on a Confluence space.',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
spaceId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the space containing the property',
},
propertyId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the property to update',
},
key: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The key/name for the property',
},
value: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description: 'The new value for the property (can be any JSON value)',
},
versionNumber: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Current version number of the property (for optimistic locking)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/space-properties',
method: 'PUT',
headers: (params: ConfluenceUpdateSpacePropertyParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceUpdateSpacePropertyParams) => ({
domain: params.domain,
accessToken: params.accessToken,
spaceId: params.spaceId?.trim(),
propertyId: params.propertyId?.trim(),
key: params.key,
value: params.value,
versionNumber: params.versionNumber,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.spaceId ?? '',
propertyId: data.id ?? '',
key: data.key ?? '',
value: data.value ?? null,
version: data.version ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'ID of the space' },
propertyId: { type: 'string', description: 'ID of the updated property' },
...SPACE_PROPERTY_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,106 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceUpdateTaskParams {
accessToken: string
domain: string
taskId: string
status: string
cloudId?: string
}
export interface ConfluenceUpdateTaskResponse {
success: boolean
output: {
ts: string
id: string
status: string
updated: boolean
}
}
export const confluenceUpdateTaskTool: ToolConfig<
ConfluenceUpdateTaskParams,
ConfluenceUpdateTaskResponse
> = {
id: 'confluence_update_task',
name: 'Confluence Update Task',
description: 'Update the status of a Confluence task (mark as complete or incomplete).',
version: '1.0.0',
oauth: {
required: true,
provider: 'confluence',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Confluence',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)',
},
taskId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the task to update',
},
status: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'New status for the task (complete or incomplete)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: () => '/api/tools/confluence/tasks',
method: 'PUT',
headers: (params: ConfluenceUpdateTaskParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceUpdateTaskParams) => ({
domain: params.domain,
accessToken: params.accessToken,
taskId: params.taskId?.trim(),
status: params.status,
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
status: data.status ?? '',
updated: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'ID of the updated task' },
status: { type: 'string', description: 'Updated task status' },
updated: { type: 'boolean', description: 'Update status' },
},
}

View File

@@ -1,3 +1,4 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceUploadAttachmentParams {
@@ -123,7 +124,7 @@ export const confluenceUploadAttachmentTool: ToolConfig<
},
outputs: {
ts: { type: 'string', description: 'Timestamp of upload' },
ts: TIMESTAMP_OUTPUT,
attachmentId: { type: 'string', description: 'Uploaded attachment ID' },
title: { type: 'string', description: 'Attachment file name' },
fileSize: { type: 'number', description: 'File size in bytes' },

View File

@@ -0,0 +1,171 @@
import type { JiraCreateComponentParams, JiraCreateComponentResponse } from '@/tools/jira/types'
import {
COMPONENT_DETAIL_ITEM_PROPERTIES,
SUCCESS_OUTPUT,
TIMESTAMP_OUTPUT,
} from '@/tools/jira/types'
import { getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraCreateComponentTool: ToolConfig<
JiraCreateComponentParams,
JiraCreateComponentResponse
> = {
id: 'jira_create_component',
name: 'Jira Create Component',
description: 'Create a new component in a Jira project',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Component name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project key (e.g., PROJ)',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Component description',
},
leadAccountId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Account ID of the component lead',
},
assigneeType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Default assignee type: PROJECT_DEFAULT, COMPONENT_LEAD, PROJECT_LEAD, or UNASSIGNED',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraCreateComponentParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/component`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: (params: JiraCreateComponentParams) => (params.cloudId ? 'POST' : 'GET'),
headers: (params: JiraCreateComponentParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: JiraCreateComponentParams) => {
if (!params.cloudId) return undefined as any
const body: Record<string, unknown> = {
name: params.name,
project: params.project.trim(),
}
if (params.description) body.description = params.description
if (params.leadAccountId) body.leadAccountId = params.leadAccountId.trim()
if (params.assigneeType) body.assigneeType = params.assigneeType
return body
},
},
transformResponse: async (response: Response, params?: JiraCreateComponentParams) => {
const createComponent = async (cloudId: string) => {
const body: Record<string, unknown> = {
name: params!.name,
project: params!.project.trim(),
}
if (params?.description) body.description = params.description
if (params?.leadAccountId) body.leadAccountId = params.leadAccountId.trim()
if (params?.assigneeType) body.assigneeType = params.assigneeType
const res = await fetch(`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/component`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
body: JSON.stringify(body),
})
if (!res.ok) {
let message = `Failed to create component (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await createComponent(cloudId)
} else {
if (!response.ok) {
let message = `Failed to create component (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
name: data.name ?? '',
description: data.description ?? null,
lead: transformUser(data.lead),
assigneeType: data.assigneeType ?? null,
project: data.project ?? null,
projectId: data.projectId ?? null,
self: data.self ?? '',
success: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
...COMPONENT_DETAIL_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,167 @@
import type { JiraCreateSprintParams, JiraCreateSprintResponse } from '@/tools/jira/types'
import { SPRINT_ITEM_PROPERTIES, SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraCreateSprintTool: ToolConfig<JiraCreateSprintParams, JiraCreateSprintResponse> = {
id: 'jira_create_sprint',
name: 'Jira Create Sprint',
description: 'Create a new sprint in a Jira board',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Sprint name',
},
boardId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Board ID to create the sprint in',
},
goal: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sprint goal',
},
startDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sprint start date (ISO 8601 format)',
},
endDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sprint end date (ISO 8601 format)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraCreateSprintParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/agile/1.0/sprint`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: (params: JiraCreateSprintParams) => (params.cloudId ? 'POST' : 'GET'),
headers: (params: JiraCreateSprintParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: JiraCreateSprintParams) => {
if (!params.cloudId) return undefined as any
const body: Record<string, unknown> = {
name: params.name,
originBoardId: params.boardId,
}
if (params.goal) body.goal = params.goal
if (params.startDate) body.startDate = params.startDate
if (params.endDate) body.endDate = params.endDate
return body
},
},
transformResponse: async (response: Response, params?: JiraCreateSprintParams) => {
const createSprint = async (cloudId: string) => {
const body: Record<string, unknown> = {
name: params!.name,
originBoardId: params!.boardId,
}
if (params?.goal) body.goal = params.goal
if (params?.startDate) body.startDate = params.startDate
if (params?.endDate) body.endDate = params.endDate
const res = await fetch(
`https://api.atlassian.com/ex/jira/${cloudId}/rest/agile/1.0/sprint`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
body: JSON.stringify(body),
}
)
if (!res.ok) {
let message = `Failed to create sprint (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await createSprint(cloudId)
} else {
if (!response.ok) {
let message = `Failed to create sprint (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? 0,
name: data.name ?? '',
state: data.state ?? '',
startDate: data.startDate ?? null,
endDate: data.endDate ?? null,
completeDate: data.completeDate ?? null,
goal: data.goal ?? null,
boardId: data.originBoardId ?? null,
self: data.self ?? '',
success: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
...SPRINT_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,186 @@
import type { JiraCreateVersionParams, JiraCreateVersionResponse } from '@/tools/jira/types'
import {
SUCCESS_OUTPUT,
TIMESTAMP_OUTPUT,
VERSION_DETAIL_ITEM_PROPERTIES,
} from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraCreateVersionTool: ToolConfig<JiraCreateVersionParams, JiraCreateVersionResponse> =
{
id: 'jira_create_version',
name: 'Jira Create Version',
description: 'Create a new version/release in a Jira project',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Version name (e.g., 1.0.0, Sprint 5)',
},
projectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID to create the version in',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Version description',
},
startDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Start date (YYYY-MM-DD)',
},
releaseDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Release date (YYYY-MM-DD)',
},
released: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the version is released (default: false)',
},
archived: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the version is archived (default: false)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraCreateVersionParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/version`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: (params: JiraCreateVersionParams) => (params.cloudId ? 'POST' : 'GET'),
headers: (params: JiraCreateVersionParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: JiraCreateVersionParams) => {
if (!params.cloudId) return undefined as any
const body: Record<string, unknown> = {
name: params.name,
projectId: Number(params.projectId.trim()),
}
if (params.description) body.description = params.description
if (params.startDate) body.startDate = params.startDate
if (params.releaseDate) body.releaseDate = params.releaseDate
if (params.released !== undefined) body.released = params.released
if (params.archived !== undefined) body.archived = params.archived
return body
},
},
transformResponse: async (response: Response, params?: JiraCreateVersionParams) => {
const createVersion = async (cloudId: string) => {
const body: Record<string, unknown> = {
name: params!.name,
projectId: Number(params!.projectId.trim()),
}
if (params?.description) body.description = params.description
if (params?.startDate) body.startDate = params.startDate
if (params?.releaseDate) body.releaseDate = params.releaseDate
if (params?.released !== undefined) body.released = params.released
if (params?.archived !== undefined) body.archived = params.archived
const res = await fetch(`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/version`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
body: JSON.stringify(body),
})
if (!res.ok) {
let message = `Failed to create version (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await createVersion(cloudId)
} else {
if (!response.ok) {
let message = `Failed to create version (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
name: data.name ?? '',
description: data.description ?? null,
released: data.released ?? false,
archived: data.archived ?? false,
startDate: data.startDate ?? null,
releaseDate: data.releaseDate ?? null,
overdue: data.overdue ?? null,
projectId: data.projectId ?? null,
self: data.self ?? '',
success: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
...VERSION_DETAIL_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,128 @@
import type { JiraDeleteComponentParams, JiraDeleteComponentResponse } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraDeleteComponentTool: ToolConfig<
JiraDeleteComponentParams,
JiraDeleteComponentResponse
> = {
id: 'jira_delete_component',
name: 'Jira Delete Component',
description: 'Delete a component from a Jira project',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
componentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Component ID to delete',
},
moveIssuesTo: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Component ID to reassign issues to (optional)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraDeleteComponentParams) => {
if (params.cloudId) {
let url = `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/component/${params.componentId.trim()}`
if (params.moveIssuesTo)
url += `?moveIssuesTo=${encodeURIComponent(params.moveIssuesTo.trim())}`
return url
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: (params: JiraDeleteComponentParams) => (params.cloudId ? 'DELETE' : 'GET'),
headers: (params: JiraDeleteComponentParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraDeleteComponentParams) => {
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
let url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/component/${params!.componentId.trim()}`
if (params?.moveIssuesTo)
url += `?moveIssuesTo=${encodeURIComponent(params.moveIssuesTo.trim())}`
const deleteResponse = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (deleteResponse.status !== 204 && !deleteResponse.ok) {
let message = `Failed to delete component (${deleteResponse.status})`
try {
const err = await deleteResponse.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
componentId: params!.componentId,
success: true,
},
}
}
if (response.status !== 204 && !response.ok) {
let message = `Failed to delete component (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
componentId: params?.componentId ?? 'unknown',
success: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
componentId: { type: 'string', description: 'Deleted component ID' },
},
}

View File

@@ -0,0 +1,116 @@
import type { JiraDeleteSprintParams, JiraDeleteSprintResponse } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraDeleteSprintTool: ToolConfig<JiraDeleteSprintParams, JiraDeleteSprintResponse> = {
id: 'jira_delete_sprint',
name: 'Jira Delete Sprint',
description: 'Delete a sprint from a Jira board',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
sprintId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Sprint ID to delete',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraDeleteSprintParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/agile/1.0/sprint/${params.sprintId}`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: (params: JiraDeleteSprintParams) => (params.cloudId ? 'DELETE' : 'GET'),
headers: (params: JiraDeleteSprintParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraDeleteSprintParams) => {
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
const deleteResponse = await fetch(
`https://api.atlassian.com/ex/jira/${cloudId}/rest/agile/1.0/sprint/${params!.sprintId}`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
}
)
if (deleteResponse.status !== 204 && !deleteResponse.ok) {
let message = `Failed to delete sprint (${deleteResponse.status})`
try {
const err = await deleteResponse.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
sprintId: params!.sprintId,
success: true,
},
}
}
if (response.status !== 204 && !response.ok) {
let message = `Failed to delete sprint (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
sprintId: params?.sprintId ?? 0,
success: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
sprintId: { type: 'number', description: 'Deleted sprint ID' },
},
}

View File

@@ -0,0 +1,145 @@
import type { JiraDeleteVersionParams, JiraDeleteVersionResponse } from '@/tools/jira/types'
import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraDeleteVersionTool: ToolConfig<JiraDeleteVersionParams, JiraDeleteVersionResponse> =
{
id: 'jira_delete_version',
name: 'Jira Delete Version',
description: 'Delete a version/release from a Jira project',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
versionId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Version ID to delete',
},
moveFixIssuesTo: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Version ID to move fix version issues to (optional)',
},
moveAffectedIssuesTo: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Version ID to move affected version issues to (optional)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraDeleteVersionParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/version/${params.versionId.trim()}/removeAndSwap`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: (params: JiraDeleteVersionParams) => (params.cloudId ? 'POST' : 'GET'),
headers: (params: JiraDeleteVersionParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: JiraDeleteVersionParams) => {
if (!params.cloudId) return undefined as any
const body: Record<string, unknown> = {}
if (params.moveFixIssuesTo) body.moveFixIssuesTo = Number(params.moveFixIssuesTo.trim())
if (params.moveAffectedIssuesTo)
body.moveAffectedIssuesTo = Number(params.moveAffectedIssuesTo.trim())
return body
},
},
transformResponse: async (response: Response, params?: JiraDeleteVersionParams) => {
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
const body: Record<string, unknown> = {}
if (params?.moveFixIssuesTo) body.moveFixIssuesTo = Number(params.moveFixIssuesTo.trim())
if (params?.moveAffectedIssuesTo)
body.moveAffectedIssuesTo = Number(params.moveAffectedIssuesTo.trim())
const deleteResponse = await fetch(
`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/version/${params!.versionId.trim()}/removeAndSwap`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
body: JSON.stringify(body),
}
)
if (deleteResponse.status !== 204 && !deleteResponse.ok) {
let message = `Failed to delete version (${deleteResponse.status})`
try {
const err = await deleteResponse.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
versionId: params!.versionId,
success: true,
},
}
}
if (response.status !== 204 && !response.ok) {
let message = `Failed to delete version (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return {
success: true,
output: {
ts: new Date().toISOString(),
versionId: params?.versionId ?? 'unknown',
success: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
success: SUCCESS_OUTPUT,
versionId: { type: 'string', description: 'Deleted version ID' },
},
}

View File

@@ -0,0 +1,162 @@
import type { JiraGetBoardSprintsParams, JiraGetBoardSprintsResponse } from '@/tools/jira/types'
import { SPRINT_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetBoardSprintsTool: ToolConfig<
JiraGetBoardSprintsParams,
JiraGetBoardSprintsResponse
> = {
id: 'jira_get_board_sprints',
name: 'Jira Get Board Sprints',
description: 'Get all sprints for a Jira board',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
boardId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Board ID to get sprints from',
},
state: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by sprint state: active, closed, future. Comma-separated for multiple.',
},
startAt: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Index of the first sprint to return (default: 0)',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of sprints to return (default: 50)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetBoardSprintsParams) => {
if (params.cloudId) {
const startAt = params.startAt ?? 0
const maxResults = params.maxResults ?? 50
let url = `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/agile/1.0/board/${params.boardId}/sprint?startAt=${startAt}&maxResults=${maxResults}`
if (params.state) url += `&state=${encodeURIComponent(params.state)}`
return url
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetBoardSprintsParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetBoardSprintsParams) => {
const fetchSprints = async (cloudId: string) => {
const startAt = params?.startAt ?? 0
const maxResults = params?.maxResults ?? 50
let url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/agile/1.0/board/${params!.boardId}/sprint?startAt=${startAt}&maxResults=${maxResults}`
if (params?.state) url += `&state=${encodeURIComponent(params.state)}`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get board sprints (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchSprints(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get board sprints (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
return {
success: true,
output: {
ts: new Date().toISOString(),
total: data.total ?? 0,
startAt: data.startAt ?? 0,
maxResults: data.maxResults ?? 0,
isLast: data.isLast ?? true,
sprints: (data.values ?? []).map((s: any) => ({
id: s.id ?? 0,
name: s.name ?? '',
state: s.state ?? '',
startDate: s.startDate ?? null,
endDate: s.endDate ?? null,
completeDate: s.completeDate ?? null,
goal: s.goal ?? null,
boardId: s.originBoardId ?? null,
self: s.self ?? '',
})),
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
total: { type: 'number', description: 'Total number of sprints' },
startAt: { type: 'number', description: 'Pagination start index' },
maxResults: { type: 'number', description: 'Maximum results per page' },
isLast: { type: 'boolean', description: 'Whether this is the last page' },
sprints: {
type: 'array',
description: 'Array of sprints',
items: {
type: 'object',
properties: SPRINT_ITEM_PROPERTIES,
},
},
},
}

View File

@@ -0,0 +1,152 @@
import type { JiraGetChangelogParams, JiraGetChangelogResponse } from '@/tools/jira/types'
import { CHANGELOG_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetChangelogTool: ToolConfig<JiraGetChangelogParams, JiraGetChangelogResponse> = {
id: 'jira_get_changelog',
name: 'Jira Get Changelog',
description: 'Get the changelog (history of changes) for a Jira issue',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
issueKey: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Jira issue key to get changelog for (e.g., PROJ-123)',
},
startAt: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Index of the first changelog entry to return (default: 0)',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of changelog entries to return (default: 100)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetChangelogParams) => {
if (params.cloudId) {
const startAt = params.startAt ?? 0
const maxResults = params.maxResults ?? 100
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey}/changelog?startAt=${startAt}&maxResults=${maxResults}`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetChangelogParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetChangelogParams) => {
const fetchChangelog = async (cloudId: string) => {
const startAt = params?.startAt ?? 0
const maxResults = params?.maxResults ?? 100
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey}/changelog?startAt=${startAt}&maxResults=${maxResults}`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get changelog (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchChangelog(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get changelog (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
return {
success: true,
output: {
ts: new Date().toISOString(),
issueKey: params?.issueKey ?? 'unknown',
total: data.total ?? 0,
startAt: data.startAt ?? 0,
maxResults: data.maxResults ?? 0,
changelog: (data.values ?? []).map((entry: any) => ({
id: entry.id ?? '',
author: transformUser(entry.author),
created: entry.created ?? '',
items: (entry.items ?? []).map((item: any) => ({
field: item.field ?? '',
fieldtype: item.fieldtype ?? '',
from: item.from ?? null,
fromString: item.fromString ?? null,
to: item.to ?? null,
toString: item.toString ?? null,
})),
})),
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
issueKey: { type: 'string', description: 'Issue key' },
total: { type: 'number', description: 'Total number of changelog entries' },
startAt: { type: 'number', description: 'Pagination start index' },
maxResults: { type: 'number', description: 'Maximum results per page' },
changelog: {
type: 'array',
description: 'Array of changelog entries',
items: {
type: 'object',
properties: CHANGELOG_ITEM_PROPERTIES,
},
},
},
}

View File

@@ -0,0 +1,154 @@
import type { JiraGetFieldsParams, JiraGetFieldsResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetFieldsTool: ToolConfig<JiraGetFieldsParams, JiraGetFieldsResponse> = {
id: 'jira_get_fields',
name: 'Jira Get Fields',
description: 'Get all fields (system and custom) available in the Jira instance',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetFieldsParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/field`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetFieldsParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetFieldsParams) => {
const fetchFields = async (cloudId: string) => {
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/field`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get fields (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchFields(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get fields (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
const fields = Array.isArray(data) ? data : []
return {
success: true,
output: {
ts: new Date().toISOString(),
total: fields.length,
fields: fields.map((f: any) => ({
id: f.id ?? '',
key: f.key ?? f.id ?? '',
name: f.name ?? '',
custom: f.custom ?? false,
orderable: f.orderable ?? false,
navigable: f.navigable ?? false,
searchable: f.searchable ?? false,
clauseNames: f.clauseNames ?? [],
schema: f.schema
? {
type: f.schema.type ?? '',
system: f.schema.system ?? null,
custom: f.schema.custom ?? null,
customId: f.schema.customId ?? null,
}
: null,
})),
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
total: { type: 'number', description: 'Total number of fields' },
fields: {
type: 'array',
description: 'Array of fields',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Field ID (e.g., summary, customfield_10001)' },
key: { type: 'string', description: 'Field key' },
name: { type: 'string', description: 'Field name' },
custom: { type: 'boolean', description: 'Whether this is a custom field' },
orderable: { type: 'boolean', description: 'Whether the field is orderable' },
navigable: { type: 'boolean', description: 'Whether the field is navigable' },
searchable: { type: 'boolean', description: 'Whether the field is searchable' },
clauseNames: {
type: 'array',
description: 'JQL clause names for this field',
items: { type: 'string' },
},
schema: {
type: 'object',
description: 'Field schema information',
properties: {
type: { type: 'string', description: 'Field type' },
system: { type: 'string', description: 'System field name', optional: true },
custom: { type: 'string', description: 'Custom field type', optional: true },
customId: { type: 'number', description: 'Custom field ID', optional: true },
},
optional: true,
},
},
},
},
},
}

View File

@@ -0,0 +1,134 @@
import type { JiraGetIssueTypesParams, JiraGetIssueTypesResponse } from '@/tools/jira/types'
import { ISSUE_TYPE_OUTPUT_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetIssueTypesTool: ToolConfig<JiraGetIssueTypesParams, JiraGetIssueTypesResponse> =
{
id: 'jira_get_issue_types',
name: 'Jira Get Issue Types',
description: 'Get all issue types available in the Jira instance or for a specific project',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
projectId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter issue types by project ID (optional)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetIssueTypesParams) => {
if (params.cloudId) {
if (params.projectId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issuetype/project?projectId=${encodeURIComponent(params.projectId.trim())}`
}
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issuetype`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetIssueTypesParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetIssueTypesParams) => {
const fetchIssueTypes = async (cloudId: string) => {
let url: string
if (params?.projectId) {
url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issuetype/project?projectId=${encodeURIComponent(params.projectId.trim())}`
} else {
url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issuetype`
}
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get issue types (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchIssueTypes(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get issue types (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
const issueTypes = Array.isArray(data) ? data : []
return {
success: true,
output: {
ts: new Date().toISOString(),
total: issueTypes.length,
issueTypes: issueTypes.map((t: any) => ({
id: t.id ?? '',
name: t.name ?? '',
description: t.description ?? null,
subtask: t.subtask ?? false,
iconUrl: t.iconUrl ?? null,
})),
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
total: { type: 'number', description: 'Total number of issue types' },
issueTypes: {
type: 'array',
description: 'Array of issue types',
items: {
type: 'object',
properties: ISSUE_TYPE_OUTPUT_PROPERTIES,
},
},
},
}

View File

@@ -0,0 +1,130 @@
import type { JiraGetLabelsParams, JiraGetLabelsResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetLabelsTool: ToolConfig<JiraGetLabelsParams, JiraGetLabelsResponse> = {
id: 'jira_get_labels',
name: 'Jira Get Labels',
description: 'Get all labels used across the Jira instance',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
startAt: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Index of the first label to return (default: 0)',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of labels to return (default: 1000)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetLabelsParams) => {
if (params.cloudId) {
const startAt = params.startAt ?? 0
const maxResults = params.maxResults ?? 1000
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/label?startAt=${startAt}&maxResults=${maxResults}`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetLabelsParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetLabelsParams) => {
const fetchLabels = async (cloudId: string) => {
const startAt = params?.startAt ?? 0
const maxResults = params?.maxResults ?? 1000
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/label?startAt=${startAt}&maxResults=${maxResults}`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get labels (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchLabels(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get labels (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
const labels: string[] = data.values ?? []
return {
success: true,
output: {
ts: new Date().toISOString(),
total: data.total ?? labels.length,
maxResults: data.maxResults ?? 0,
isLast: data.isLast ?? true,
labels,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
total: { type: 'number', description: 'Total number of labels' },
maxResults: { type: 'number', description: 'Maximum results per page' },
isLast: { type: 'boolean', description: 'Whether this is the last page' },
labels: {
type: 'array',
description: 'Array of label names',
items: { type: 'string' },
},
},
}

View File

@@ -0,0 +1,125 @@
import type { JiraGetLinkTypesParams, JiraGetLinkTypesResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetLinkTypesTool: ToolConfig<JiraGetLinkTypesParams, JiraGetLinkTypesResponse> = {
id: 'jira_get_link_types',
name: 'Jira Get Link Types',
description: 'Get all available issue link types (e.g., Blocks, Relates, Duplicates)',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetLinkTypesParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issueLinkType`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetLinkTypesParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetLinkTypesParams) => {
const fetchLinkTypes = async (cloudId: string) => {
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issueLinkType`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get link types (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchLinkTypes(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get link types (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
const linkTypes = data.issueLinkTypes ?? []
return {
success: true,
output: {
ts: new Date().toISOString(),
total: linkTypes.length,
linkTypes: linkTypes.map((lt: any) => ({
id: lt.id ?? '',
name: lt.name ?? '',
inward: lt.inward ?? '',
outward: lt.outward ?? '',
self: lt.self ?? '',
})),
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
total: { type: 'number', description: 'Total number of link types' },
linkTypes: {
type: 'array',
description: 'Array of issue link types',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Link type ID' },
name: { type: 'string', description: 'Link type name (e.g., Blocks, Relates)' },
inward: { type: 'string', description: 'Inward description (e.g., is blocked by)' },
outward: { type: 'string', description: 'Outward description (e.g., blocks)' },
self: { type: 'string', description: 'REST API URL for this link type' },
},
},
},
},
}

View File

@@ -0,0 +1,111 @@
import type { JiraGetMyselfParams, JiraGetMyselfResponse } from '@/tools/jira/types'
import { TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetMyselfTool: ToolConfig<JiraGetMyselfParams, JiraGetMyselfResponse> = {
id: 'jira_get_myself',
name: 'Jira Get Current User',
description: 'Get details of the currently authenticated Jira user',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetMyselfParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/myself`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetMyselfParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetMyselfParams) => {
const fetchMyself = async (cloudId: string) => {
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/myself`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get current user (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchMyself(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get current user (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
return {
success: true,
output: {
ts: new Date().toISOString(),
accountId: data.accountId ?? '',
displayName: data.displayName ?? '',
active: data.active ?? null,
emailAddress: data.emailAddress ?? null,
avatarUrl: data.avatarUrls?.['48x48'] ?? null,
accountType: data.accountType ?? null,
timeZone: data.timeZone ?? null,
locale: data.locale ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
...USER_OUTPUT_PROPERTIES,
locale: { type: 'string', description: 'User locale (e.g., en_US)', optional: true },
},
}

View File

@@ -0,0 +1,118 @@
import type { JiraGetPrioritiesParams, JiraGetPrioritiesResponse } from '@/tools/jira/types'
import { PRIORITY_OUTPUT_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetPrioritiesTool: ToolConfig<JiraGetPrioritiesParams, JiraGetPrioritiesResponse> =
{
id: 'jira_get_priorities',
name: 'Jira Get Priorities',
description: 'Get all issue priorities available in the Jira instance',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetPrioritiesParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/priority`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetPrioritiesParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetPrioritiesParams) => {
const fetchPriorities = async (cloudId: string) => {
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/priority`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get priorities (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchPriorities(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get priorities (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
const priorities = Array.isArray(data) ? data : []
return {
success: true,
output: {
ts: new Date().toISOString(),
total: priorities.length,
priorities: priorities.map((p: any) => ({
id: p.id ?? '',
name: p.name ?? '',
iconUrl: p.iconUrl ?? null,
})),
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
total: { type: 'number', description: 'Total number of priorities' },
priorities: {
type: 'array',
description: 'Array of priorities',
items: {
type: 'object',
properties: PRIORITY_OUTPUT_PROPERTIES,
},
},
},
}

View File

@@ -0,0 +1,119 @@
import type { JiraGetProjectParams, JiraGetProjectResponse } from '@/tools/jira/types'
import { PROJECT_DETAIL_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetProjectTool: ToolConfig<JiraGetProjectParams, JiraGetProjectResponse> = {
id: 'jira_get_project',
name: 'Jira Get Project',
description: 'Get details of a specific Jira project',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
projectKeyOrId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project key (e.g., PROJ) or ID',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetProjectParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/project/${encodeURIComponent(params.projectKeyOrId.trim())}?expand=lead,description`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetProjectParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetProjectParams) => {
const fetchProject = async (cloudId: string) => {
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${encodeURIComponent(params!.projectKeyOrId.trim())}?expand=lead,description`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get Jira project (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchProject(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get Jira project (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
return {
success: true,
output: {
ts: new Date().toISOString(),
id: data.id ?? '',
key: data.key ?? '',
name: data.name ?? '',
description: data.description ?? null,
projectTypeKey: data.projectTypeKey ?? null,
style: data.style ?? null,
simplified: data.simplified ?? null,
self: data.self ?? '',
url: data.url ?? null,
lead: transformUser(data.lead),
avatarUrl: data.avatarUrls?.['48x48'] ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
...PROJECT_DETAIL_ITEM_PROPERTIES,
},
}

View File

@@ -0,0 +1,136 @@
import type {
JiraGetProjectComponentsParams,
JiraGetProjectComponentsResponse,
} from '@/tools/jira/types'
import { COMPONENT_DETAIL_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types'
import { getJiraCloudId, transformUser } from '@/tools/jira/utils'
import type { ToolConfig } from '@/tools/types'
export const jiraGetProjectComponentsTool: ToolConfig<
JiraGetProjectComponentsParams,
JiraGetProjectComponentsResponse
> = {
id: 'jira_get_project_components',
name: 'Jira Get Project Components',
description: 'Get all components for a Jira project',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira',
},
domain: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
projectKeyOrId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project key (e.g., PROJ) or ID',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description:
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
},
},
request: {
url: (params: JiraGetProjectComponentsParams) => {
if (params.cloudId) {
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/project/${encodeURIComponent(params.projectKeyOrId.trim())}/components`
}
return 'https://api.atlassian.com/oauth/token/accessible-resources'
},
method: 'GET',
headers: (params: JiraGetProjectComponentsParams) => ({
Accept: 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response: Response, params?: JiraGetProjectComponentsParams) => {
const fetchComponents = async (cloudId: string) => {
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${encodeURIComponent(params!.projectKeyOrId.trim())}/components`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${params!.accessToken}`,
},
})
if (!res.ok) {
let message = `Failed to get project components (${res.status})`
try {
const err = await res.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
return res.json()
}
let data: any
if (!params?.cloudId) {
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
data = await fetchComponents(cloudId)
} else {
if (!response.ok) {
let message = `Failed to get project components (${response.status})`
try {
const err = await response.json()
message = err?.errorMessages?.join(', ') || err?.message || message
} catch (_e) {}
throw new Error(message)
}
data = await response.json()
}
const components = Array.isArray(data) ? data : []
return {
success: true,
output: {
ts: new Date().toISOString(),
projectKeyOrId: params?.projectKeyOrId ?? 'unknown',
total: components.length,
components: components.map((c: any) => ({
id: c.id ?? '',
name: c.name ?? '',
description: c.description ?? null,
lead: transformUser(c.lead),
assigneeType: c.assigneeType ?? null,
project: c.project ?? null,
projectId: c.projectId ?? null,
self: c.self ?? '',
})),
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
projectKeyOrId: { type: 'string', description: 'Project key or ID' },
total: { type: 'number', description: 'Total number of components' },
components: {
type: 'array',
description: 'Array of project components',
items: {
type: 'object',
properties: COMPONENT_DETAIL_ITEM_PROPERTIES,
},
},
},
}

Some files were not shown because too many files have changed in this diff Show More